diff --git a/.gitignore b/.gitignore index 3ac9ef1c2d..15da508178 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,9 @@ docs/blog-preview-*/ # Finder duplicate files, e.g. "spec 2.js", "sitemap 2.xml" *\ 2.* *\ [0-9].* + +# Third-party / licensed theme packs live outside the repo (point MD_THEMES_DIR at them). +# The only built-in pack is resources/themes/plain. +/resources/themes/* +!/resources/themes/plain/ +/my-themes/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 10634e499f..8d1ba493f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] +### Fork changes (agent-stage) + +- **The lead agent is the `orchestrator`.** The old agent id is gone from code, config keys + (`orchestratorProvider`/`orchestratorModel`), prompts and UI. Existing hives migrate on first + start (registry and `agents/` folder renamed, `registry.json` backed up as + `.pre-orchestrator-rename`), and the router still accepts the old address. The name people see + is the user's rename, else the active theme's `copy.orchestratorName`, else `Orchestrator`. +- **A rights-clean default theme.** `plain` is generated from flat shapes. The Office, Brooklyn 99 + and Star Trek packs and the bundled LimeZu tilesets are removed from the tree; keep them in your + own themes folder. Characters are generic and drawn procedurally. +- **Theme tooling.** The picker is always on, validates packs with the real validator, lists each + problem, and has Rescan, Open folder and an extra themes folder. `npm run theme:validate` and + `npm run theme:new`; contract in `docs/theming/THEMING.md`. +- **Usage endpoint at standup.** Optional URL and bearer token; the hourly standup includes a + summary and writes `usage-availability.json`. See `docs/usage-endpoint.md`. +- **Gemini 4 Argon** in the Antigravity and Gemini CLI model lists. +- **Codex submit fix.** Longer Enter delay with a second Enter, plus a safety-net interval for + quiet PTYs. + ### Added - **Tasks show their id.** The one thing people actually refer to a card by — `bmt-12` — was not diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4407152f40..10b9525d0d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,198 +1,51 @@ -# Contributing to Munder Difflin +# Contributing to Agent Stage -Thanks for your interest! This is an early prototype, so there's a lot of surface -area and plenty of room to help. This guide covers setup, the gotchas, and the -conventions that keep the codebase coherent. +Contributions are welcome through pull requests. Keep them easy to review. -## Code of Conduct +## Rules for pull requests -This project follows the [Contributor Covenant](./CODE_OF_CONDUCT.md). By -participating, you agree to uphold it. +- Open a PR when the change is ready. You do not need permission first for a focused fix or improvement. +- One fix, improvement, or refactor per PR. Split unrelated work. +- Keep the diff small. Do not mix a behavior change with cleanup, formatting, or a broad rewrite unless all of it is necessary for that single change. +- Write a clear title and description that state what changed, why it changed, and how you verified it. +- Include screenshots or a short recording for visual changes. Show the same state before and after when that makes the result clearer. +- Add a test when it meaningfully protects changed behavior or a regression. Tests are not ceremony; they should earn their maintenance cost. -## Before you start +## Before opening a PR -These are the constraints most pull requests trip over. Reading them first is -much cheaper than finding out in review. +1. Read your diff. Remove debug output, dead code, unrelated formatting, and generated files. +2. Run the relevant checks. For most code changes, start with: -- **Keep the change scoped to one clear improvement, fix, or refactor.** A fix - plus a rename plus a refactor is three pull requests, and all three merge - faster than the one. -- **Munder Difflin targets macOS, Windows and Linux.** Every change has to work - on all three unless it sits behind an explicit runtime platform check. Most - of our cross-platform bugs are paths: use `path.join` and the Node path - helpers, never a hand-built `"a/b"` string. -- **Paths with spaces are real.** Several shipped bugs came from a hive folder - living under a directory with a space in its name. Quote, and test one. -- **We support twelve agent CLIs and counting.** Keep shared behaviour provider - neutral and put provider specific logic behind an explicit check. Anything - that assumes Claude Code specifically will break the other eleven. -- **Do not assume the local machine.** Agents run against their own working - directories and their own environments; a process, file, credential or shell - that exists on yours may not exist on theirs. -- **New UI derives from the design tokens.** [`DESIGN.md`](./DESIGN.md) is - canonical. No ad-hoc colors, spacing or fonts. + ```bash + npm run typecheck + npm run test:focused + ``` -## Development setup +3. For UI work, verify the running Electron app and include visual evidence. +4. For a behavior change, add or update a test when it provides useful regression coverage. +5. If you change design surfaces, use the tokens and conventions in [`DESIGN.md`](./DESIGN.md). -### Prerequisites +## Working in the repository -- **macOS, Windows, or Linux** — signed/notarized macOS builds, plus Windows and - Linux builds, ship from the [releases page](https://github.com/chaitanyagiri/munder-difflin/releases/latest). - Cross-platform smoke-testing and fixes are still very welcome (see - [Good first areas](#good-first-areas)). -- **Node.js 18+** and npm. -- A **C/C++ toolchain** to build `node-pty`'s native addon. On macOS: - ```bash - xcode-select --install - ``` - On Windows/Linux, follow [`node-pty`'s own prerequisites](https://github.com/microsoft/node-pty#dependencies). -- **[Claude Code](https://claude.com/claude-code)** on your `PATH` if you want - agents to actually run `claude` (the default command). Any other command works. +- Agent Stage supports macOS, Windows, and Linux. Use platform-safe paths and avoid shell assumptions. +- Quote paths and account for directories with spaces. +- Keep provider-specific behavior behind an explicit check. Shared behavior must remain provider-neutral. +- Do not assume another developer's files, credentials, processes, or shell are available. +- Do not commit credentials, tokens, customer data, `node_modules`, build output, or generated artifacts. -### Install & run +## Development setup ```bash -git clone munder-difflin -cd munder-difflin -npm install # postinstall rebuilds node-pty against Electron's ABI -npm run dev # live-reloading Electron build +git clone agent-stage +cd agent-stage +npm install +npm run dev ``` -> [!IMPORTANT] -> **The most common setup failure is the native `node-pty` rebuild.** The -> `postinstall` script runs `electron-rebuild` so `node-pty` matches Electron's -> ABI. If you see a "wrong ELF/Mach-O" or "NODE_MODULE_VERSION" error at launch, -> re-run `npm install` (which re-triggers `postinstall`) after confirming your -> C/C++ toolchain is installed. - -## Evidence is mandatory - -**Every pull request must show a before and an after.** Screenshots, or a screen -recording when the thing moves. They go in the PR description, under the -`### Before` and `### After` headings the template gives you. - -This is enforced. The `PR evidence` check runs the moment you open a PR, and a -PR that fails it does not merge. Fix the description and the check re-runs on -save. It reads each heading separately, so two images dumped under one of them -will not pass — a reviewer has to be able to tell which is which. - -**"My change has no UI" is not an exemption.** It changes the evidence, not the -requirement: - -| Kind of change | What before/after looks like | -|---|---| -| Visual | The same view, twice. Same window size, same theme, same data. | -| Bug fix | The bug happening, then the same steps not producing it. | -| Terminal / CLI | A recording of the session, or the output pasted as text. | -| Performance | The measurement before and the measurement after, same machine. | -| Crash / hang | The failure, then the same path completing. | -| Test-only | The suite red, then the suite green. | - -Make the two shots comparable. Different window sizes, a light shot against a -dark one, or different data means the reviewer is diffing your screenshots -instead of your change. - -The only way out is the `no-visual-change` label, which a maintainer applies -for things with genuinely nothing observable: a CI tweak, a typo, a dependency -bump. You cannot add it yourself, and asking for it on a change that does -something visible will cost you more time than the screenshot would have. - -## Before you open a PR - -1. **Attach the before and after.** See above. This is the one that gets PRs - closed unread, so do it first, not last. -2. **Keep the type-checker green:** `npm run typecheck` (runs both the node and - web TS projects). -3. **Run the tests:** `npm run test:focused`. If you changed behaviour, add a - test for it — a bug fix with no test is a bug fix that comes back. -4. **Confirm a production build works:** `npm run build`. -5. **Match the aesthetic.** Any new UI **must** derive from the design tokens in - [`DESIGN.md`](./DESIGN.md) / `src/renderer/src/design/tokens.ts` — no ad-hoc - colors, spacing, or fonts. `tokens.ts` and `tokens.css` are mirrored; if you - change one, change both. -6. **Read your own diff.** Every line of it. Debug logging, commented-out code, - and reformatting of files you didn't otherwise touch all get a PR sent back. -7. **Run your coding agent over your own PR, and paste what it found.** We ship - an agent harness; use one. Ask it to check specifically for cross-platform - behaviour, paths with spaces, whether the change stays provider neutral - across the supported CLIs, performance in hot paths, and obvious security - risk. A short honest summary including what it flagged and you decided not to - change is worth more than a clean one, and it usually saves a review round - trip. This is asked for, not required. - -## Where the bar is - -We get more pull requests than we can review carefully, so the bar is high and -it is easier for everyone if it is written down. Clear the list above and you -are almost certainly fine. The items below are the ones we close rather than -negotiate, and every one of them is cheaper to avoid than to fix in review: - -- **No before/after evidence**, and no `no-visual-change` label. -- **More than one change in one PR.** A fix plus a refactor plus a rename is - three PRs. Split it and every one of them merges faster. -- **Wholesale reformatting** of files, or a diff where the real change is buried - in whitespace and import reordering. -- **A rewrite nobody asked for.** Large architectural changes need an issue or a - [discussion](https://github.com/chaitanyagiri/munder-difflin/discussions) with - agreement **before** you write the code. We would rather say no to a paragraph - than to a week of your work. -- **Generated or unattributed content** — art that isn't yours or compatibly - licensed, or a description that doesn't match what the diff does. -- **Dependency additions** that aren't justified in the description. A new - runtime dependency needs a reason a one-file helper wouldn't have solved. -- **No response for 14 days** on review feedback. Reopen whenever you're ready; - nothing is lost. - -None of this is aimed at first-timers. A small, focused, well-evidenced PR from -someone who has never contributed before gets reviewed ahead of a big one from -someone who has. - -## Project layout - -| Path | What lives there | -|---|---| -| `src/main/` | Electron main process — PTYs (`pty.ts`), fs/git bridges, the hive (`hive.ts`, `hooks.ts`, `memory.ts`), config. | -| `src/preload/` | Context-bridge IPC surface. | -| `src/renderer/` | React UI, Pixi.js office scene (`scene/office/`), components, design system, stores. | -| `tools/mapgen/` | Python helpers for building/rendering the Tiled office map. | - -See [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) for the data-flow overview, -the module by module layout, and the design system. - -## Good first areas - -- **Wiring real Claude Code hook events** — avatar behavior is currently driven - by a mock event loop (`src/renderer/src/store/mockEvents.ts`). Replacing it - with real tool events is the headline next milestone. -- The add-agent flow and config drawer. -- Cross-platform smoke-testing — Windows and Linux builds ship, but real-world - coverage (WSL2, various distros, uncommon shells) is thin. - -## Commit & PR conventions - -- Branch off `main`. One change per PR — see - [What gets a PR closed](#what-gets-a-pr-closed). -- Write a clear description of *what* changed and *why*. We can read the diff; - we cannot read your reasoning. -- Say how you tested it, and on which OS. "Tested locally" tells us nothing. -- Link the issue you're fixing: `Closes #123`. -- Don't commit `node_modules/`, `out/`, or built artifacts (already gitignored). -- Don't force-push after a review has started. It throws away the comparison the - reviewer was working from. -- **Never put credentials, tokens, internal metrics, or customer data in a - commit message.** A pushed commit message is public and permanent, and no - later rewrite retracts it once it has been fetched. - -## A note on assets +Agent Stage requires Node.js 18+ and a C/C++ toolchain for `node-pty`. If an Electron upgrade leaves `node-pty` with an ABI error, run `npm install` again. -The bundled tilesets are LimeZu's *Modern Interiors*, used under the **Complete -Version licence**. See [`ATTRIBUTION.md`](./src/renderer/src/assets/ATTRIBUTION.md). -That licence requires credit to LimeZu, so don't remove the acknowledgement from -the README, the app, or the website. The Office cast is drawn procedurally in -`portraitArt.ts` and carries no third-party licence. -If you contribute new art, it must be either your own work or compatibly -licensed, and you must add it to `ATTRIBUTION.md`. Don't add unlicensed assets. +## Assets and questions -## Questions +Contributed art must be original or compatibly licensed, with attribution where needed. Do not add third-party theme packs or unlicensed assets to the repo. -Open a [discussion or issue](../../issues) — happy to help you get oriented. +Use a GitHub issue or discussion for questions that need coordination before a large change. diff --git a/DESIGN.md b/DESIGN.md index 4c4bc4c709..2daca38ead 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -85,7 +85,7 @@ Saturated and warm. Each avatar gets one — the strip badge, the agent's chat s | `status-idle` | `#A899B5` | Agent at desk, awaiting | | `status-thinking` | `#4ECDC4` | Reasoning + en route to a station | | `status-working` | `#FFD93D` | At a station, using a tool | -| `status-waiting` | `#6C8EF5` | Worker stalled on god or another agent | +| `status-waiting` | `#6C8EF5` | Worker stalled on orchestrator or another agent | | `status-blocked` | `#FF6B6B` | Notification fired, needs user | | `status-success` | `#6BCF7F` | Just finished | | `status-ghost` | `#D9CFE0` | Pane closed, fading out | @@ -285,7 +285,7 @@ Color: status palette. Background: status-color at 20% opacity over cream-100. ``` Labels are not the token names — they read from the *user's* side. `blocked` shows -"needs you" (reserved for the god agent waiting on you), `waiting` stays "waiting" +"needs you" (reserved for the orchestrator waiting on you), `waiting` stays "waiting" (honest about the worker being stalled on another agent), and `typing` shows **"your draft"** — it is your text on the prompt, not the agent's, and it is why that agent's message queue is not draining. See diff --git a/HIVE.md b/HIVE.md index 2ec9e97e99..ad98c9d59f 100644 --- a/HIVE.md +++ b/HIVE.md @@ -2,7 +2,7 @@ > How Munder Difflin turns a room full of independent `claude` > processes into a collaborating, self-coordinating team with persistent memory, -> a shared blackboard, and a "god" orchestrator that runs the floor. +> a shared blackboard, and a "orchestrator" orchestrator that runs the floor. This document is the design source of truth for the agent-collaboration layer. It sits alongside [`SPEC.md`](./SPEC.md) (terminal/event plane) and @@ -22,7 +22,7 @@ prompt, and a hook lifecycle. We layer four classic patterns on top: | Writing a requirement into another agent's file | **Stigmergy** — coordinating by modifying a shared environment | | A shared plan multiple agents edit | **Blackboard architecture** (Hearsay-II) | | "Check after finishing every task" | **Mailbox / actor model** — drain an inbox at a lifecycle point | -| A "god" agent that runs the floor and clarifies for others | **Orchestrator / supervisor** (LangGraph-supervisor-style) | +| A "orchestrator" agent that runs the floor and clarifies for others | **Orchestrator / supervisor** (LangGraph-supervisor-style) | The umbrella term is a **multi-agent system (MAS)** with **autonomous agent loops**. The closest academic analogue to this app is Stanford's *Generative @@ -42,11 +42,11 @@ stream, retrieval, reflection, and planning. `agents//` directory. Cross-agent delivery happens by the **router** (main process) moving messages from a sender's `outbox/` into a recipient's `inbox/`. No file is ever written by two processes. -3. **God-mode autonomy, native HITL.** A privileged **god agent** (lives in +3. **Orchestrator-mode autonomy, native HITL.** A privileged **orchestrator** (lives in Michael's room) adjudicates cross-agent traffic. Routine requests (clarifications, data asks, plan tweaks) it resolves itself and the system keeps running fully autonomously. **Critical** items (destructive ops, spend, - scope changes, unresolvable conflicts) route to the god, who surfaces them to + scope changes, unresolvable conflicts) route to the orchestrator, who surfaces them to the human natively in his own Claude Code session — there is no separate approval queue. Tool-permission prompts are the HITL gate, and they're approvable remotely from a phone via `/remote-control`. @@ -86,7 +86,7 @@ Design rules that make this robust: - **One JSON file per message**, written via temp-file + atomic `rename` — never a co-edited shared mailbox file (those conflict under git). - **Append-only** `log.jsonl`; consumers track their own cursor. -- `board.md` is the one genuinely co-edited file — it goes through the god agent +- `board.md` is the one genuinely co-edited file — it goes through the orchestrator (single scribe) to avoid conflicts. --- @@ -102,20 +102,20 @@ LISP syntax. Seven semantic fields: "conversation": "conv-7f3", // groups a thread "in_reply_to": " | null", "from": "agent.researcher", - "to": "agent.coder | god | broadcast", + "to": "agent.coder | orchestrator | broadcast", "act": "request | inform | propose | query | agree | refuse | done", "subject": "short human-readable summary", "body": "free text / markdown / structured payload", "hops": 3, // ++ per reply; capped to kill ping-pong loops "requires_reply": true, // only request/query/propose obligate a reply - "needs_human": false, // router/god may flip this to escalate + "needs_human": false, // router/orchestrator may flip this to escalate "created_at": "ISO-8601" } ``` Anti-livelock rules: only `request`/`query`/`propose` obligate a reply (pure `inform`/`done` are terminal); every reply increments `hops`; past a hop cap the -god agent escalates instead of letting two agents loop forever; re-seeing a +orchestrator escalates instead of letting two agents loop forever; re-seeing a processed `id` is a no-op (idempotent via cursor). --- @@ -128,8 +128,8 @@ agent B mid-task needs something from agent C ▼ ┌─────────────────────── main process (the harness) ───────────────────────┐ │ Router watches every outbox/ │ -│ → deliver to agents/C/inbox/ (to:"human" → routed to the god proxy; │ -│ the god surfaces critical calls natively in its own session) │ +│ → deliver to agents/C/inbox/ (to:"human" → routed to the orchestrator proxy; │ +│ the orchestrator surfaces critical calls natively in its own session) │ │ → append to log.jsonl → git commit (single committer, retry+backoff) │ └──────────────────────────────────────────────────────────────────────────┘ │ delivered to C's inbox @@ -146,16 +146,16 @@ an agent to the right station (replacing today's `mockEvents.ts` / PTY-scraping) --- -## 6. The god agent (orchestrator) +## 6. The orchestrator (orchestrator) A fixed, always-on agent seated at `desk-ceo` (Michael's room), `character: -michael`, flagged `isGod`. It is an ordinary `claude` process — the *intelligence* +michael`, flagged `isOrchestrator`. It is an ordinary `claude` process — the *intelligence* — while the main process is the *mechanism* (git, sockets, routing). It owns: - **Roster & routing** (`registry.json`): who exists, their capabilities, status. - **Adjudication**: read each outbound request; resolve routine ones itself (answer clarifications, route to the right specialist with a self-contained - task spec), escalate only critical ones. This is "god mode." + task spec), escalate only critical ones. This is "orchestrator mode." - **Blackboard scribe**: the single writer of `board.md`, so shared plans never conflict. - **Task ledger** (`tasks.json`): assign, track, retry, checkpoint. @@ -175,10 +175,10 @@ is the primary control surface — tune the prompt, not the code. agent via `--settings`) + `Stop`-loop so agents drain their inbox automatically and keep running (guarded by `stop_hook_active` + cursor); hook events stream to the renderer to drive avatars. -- **Phase 2 — God mode** ✅: the god agent auto-spawns into Michael's room +- **Phase 2 — Orchestrator mode** ✅: the orchestrator auto-spawns into Michael's room (`desk-ceo` reserved) and, on a fresh spawn, is started with `/remote-control` (best-effort) plus an orientation prompt so it begins running the floor on its - own. The router routes `to:"human"` traffic to the god (the human's proxy); + own. The router routes `to:"human"` traffic to the orchestrator (the human's proxy); there is no separate approval queue — human-in-the-loop is native to each agent's Claude Code session (permission prompts, approvable remotely from a phone). Idle agents are woken when they hold unread inbox messages. @@ -201,7 +201,7 @@ is the primary control surface — tune the prompt, not the code. | --- | --- | | `index.lock` corruption | Single committer (main process), retry+backoff, stale-lock cleanup | | Infinite Stop-hook loop | Guard on `stop_hook_active`; `hops` cap; `CLAUDE_CODE_STOP_HOOK_BLOCK_CAP` | -| Two agents ping-ponging | Only request/query/propose obligate replies; hop cap → god escalates | +| Two agents ping-ponging | Only request/query/propose obligate replies; hop cap → orchestrator escalates | | Reprocessing messages | Per-agent `cursor.json`; processed messages move to `inbox/.done/` | | `memory.md` unbounded growth | Phase 3 reflection/summarization | | Modifying the user's repo with hooks | Write hooks to `/.claude/settings.local.json` (gitignored convention) | diff --git a/LICENSE b/LICENSE index 69073fc3d4..b9b573af83 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2026 Chaitanya Giri +Copyright (c) 2026 Richard J Tindell II Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/LICENSE-ASSETS b/LICENSE-ASSETS index 5eec3aa1e5..56bb51c89c 100644 --- a/LICENSE-ASSETS +++ b/LICENSE-ASSETS @@ -1,14 +1,11 @@ NOTE ON BUNDLED ART ASSETS -The MIT License in LICENSE applies to the SOURCE CODE of this project. It does -NOT apply to the bundled pixel-art assets under src/renderer/src/assets/ -(tilesets and maps). Those are "Modern Interiors - RPG Tileset [16X16]" by -LimeZu, licensed separately under the LimeZu Complete Version license, which -permits editing and use in commercial and non-commercial projects and REQUIRES -credit to LimeZu (https://limezu.itch.io/). +The MIT License in LICENSE covers the whole repository, including the built-in +"plain" theme pack (resources/themes/plain). That pack is generated by +scripts/theme-packs/plain/gen_pack.py from flat geometric shapes and contains no +third-party artwork. Characters are drawn procedurally in +src/renderer/src/scene/office/portraitArt.ts. -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. - -See src/renderer/src/assets/ATTRIBUTION.md for details. +Third-party theme packs (for example ones built from commercial tilesets) are NOT +part of this repository. Install them in your themes folder or via MD_THEMES_DIR +and follow the licence of whoever made them. See docs/theming/THEMING.md. diff --git a/MEMORY_GRAPH_SPEC.md b/MEMORY_GRAPH_SPEC.md index 128f5f2bf3..994125c3bd 100644 --- a/MEMORY_GRAPH_SPEC.md +++ b/MEMORY_GRAPH_SPEC.md @@ -1,13 +1,13 @@ # Memory Graph Visualization — Spec (Phase 1) **Feature #8** of the Munder Difflin harness roadmap · author: Jim · branch `feature/memory-graph` -**Status:** awaiting god sign-off. No component code is written yet — this document is the contract for Phase 2. +**Status:** awaiting orchestrator sign-off. No component code is written yet — this document is the contract for Phase 2. --- ## 1. Goal -Give Michael (god) a single glance answer to: *who is talking to whom, and what does the hive collectively know?* +Give Michael (orchestrator) a single glance answer to: *who is talking to whom, and what does the hive collectively know?* The graph is a new **`graph` tab** in the Command Center (`CommandCenterPanel.tsx`) that draws the hive as a network — agents as nodes, the messages between them as edges, and (optionally) the topics each agent's memory file covers as a second layer. It is a read + navigate surface: click a node to jump into that agent's memory; hover to preview without leaving the graph. @@ -21,8 +21,8 @@ Everything comes from the existing preload bridge — **no new IPC handlers, no | Source | Signature (already in `src/preload/index.ts`) | Used for | |---|---|---| -| Roster | `useStore((s) => s.agents)` → `Agent[]` | Agent nodes (id, name, accent, character, status, isGod) | -| Registry | `window.cth.hiveRegistry()` → `HiveRegistry` | Fallback roster + `godId` + lastSeen, if store is thin | +| Roster | `useStore((s) => s.agents)` → `Agent[]` | Agent nodes (id, name, accent, character, status, isOrchestrator) | +| Registry | `window.cth.hiveRegistry()` → `HiveRegistry` | Fallback roster + `orchestratorId` + lastSeen, if store is thin | | Message log | `window.cth.hiveLog(200)` → `LogEntry[]` | Message edges (`kind:'message'`, `from`, `to`, `act`, `subject`) | | Per-agent memory | `window.cth.hiveMemory(id)` → `string` (raw markdown) | Topic nodes + agent↔topic edges | @@ -31,7 +31,7 @@ Everything comes from the existing preload bridge — **no new IPC handlers, no ```ts // Agent (store) — the primary node payload { id: string; name: string; accent: AccentColorName; character: OfficeCharacterName; - status: StatusKind; isGod?: boolean } + status: StatusKind; isOrchestrator?: boolean } // LogEntry (hiveLog) — loosely typed; we read the 'message' kind only { ts?: number; kind?: string; from?: string; to?: string; act?: MessageAct; subject?: string } @@ -47,7 +47,7 @@ Log entries use agent **ids** (and the literal strings `"broadcast"` and `"human - id present in roster → that agent node. - `"broadcast"` → fan the edge from sender to **every** other agent (thin, dashed) — or, if it clutters, collapse to a single edge into a synthetic `broadcast` pseudo-node. **Decision: collapse to a `broadcast` pseudo-node** (square, ink-300, labelled "broadcast") to keep edge count linear. -- `"human"` / `"god→human"` escalations → a single synthetic `human` node (distinct shape: double-border square, lemon accent). +- `"human"` / `"orchestrator→human"` escalations → a single synthetic `human` node (distinct shape: double-border square, lemon accent). - Unknown id → skip the edge (defensive; logged to console once). --- @@ -57,12 +57,12 @@ Log entries use agent **ids** (and the literal strings `"broadcast"` and `"human ```ts type GraphNode = | { kind: 'agent'; id: string; label: string; accent: AccentColorName; - status: StatusKind; isGod: boolean; degree: number } + status: StatusKind; isOrchestrator: boolean; degree: number } | { kind: 'topic'; id: string; label: string; weight: number /* # agents mentioning */ } | { kind: 'pseudo'; id: 'broadcast' | 'human'; label: string }; ``` -- **Agent nodes (primary, always on).** Square tile, filled with the agent's `--cth-` colour, hard 2px ink-900 offset shadow (DESIGN.md neo-brutalist). God is larger (1.4×) and gets a double border. Node size scales gently with **degree** (message count) so the busiest agents read as hubs. +- **Agent nodes (primary, always on).** Square tile, filled with the agent's `--cth-` colour, hard 2px ink-900 offset shadow (DESIGN.md neo-brutalist). Orchestrator is larger (1.4×) and gets a double border. Node size scales gently with **degree** (message count) so the busiest agents read as hubs. - **Topic nodes (secondary, toggleable — default OFF).** Small cream-200 squares with an ink-700 hairline border, VT323 label. Only shown when the "topics" toggle is on, to avoid overwhelming the default view. - **Pseudo nodes** (`broadcast`, `human`): ink-300 / lemon, visually subdued, never navigable. @@ -104,7 +104,7 @@ This is heuristic, not semantic. **Note:** MemPalace (`window.cth.searchMemory`) ## 6. Layout — **force-directed** (chosen) -A spring/charge simulation: edges pull connected nodes together, all nodes repel each other, a mild centring force keeps the whole thing on-screen, and god gets a small extra gravity toward centre so it reads as the orchestrator hub. +A spring/charge simulation: edges pull connected nodes together, all nodes repel each other, a mild centring force keeps the whole thing on-screen, and orchestrator gets a small extra gravity toward centre so it reads as the orchestrator hub. **Why force-directed over the alternatives:** @@ -112,9 +112,9 @@ A spring/charge simulation: edges pull connected nodes together, all nodes repel |---|---| | **Force-directed** ✅ | The relationships are an arbitrary mesh (any agent can message any other; topics wire to multiple agents). Force layout is the only one that makes *clusters* legible — tightly-collaborating agents drift together, isolated agents drift out. It handles both the agent-agent mesh and the bipartite agent-topic layer in one model. Dynamic data (new agents/messages each poll) settles gracefully. | | Timeline | Rejected. We have timestamps, but the question this tab answers is *structural* (who↔who, who-knows-what), not *temporal*. The Activity tab already serves the chronological view. A timeline also can't express the bipartite topic layer. | -| Radial | Tempting (god is a natural centre) — but it hard-codes a single hub and flattens agent-agent edges that don't pass through god. Force-directed gives the same "god in the middle" read *emergently* (via god-gravity + god's high degree) without losing peer-to-peer structure. | +| Radial | Tempting (orchestrator is a natural centre) — but it hard-codes a single hub and flattens agent-agent edges that don't pass through orchestrator. Force-directed gives the same "orchestrator in the middle" read *emergently* (via orchestrator-gravity + orchestrator's high degree) without losing peer-to-peer structure. | -**Implementation:** a tiny hand-rolled simulation (~50 lines: Coulomb repulsion + Hooke spring + centre gravity + velocity damping), run for a fixed number of ticks on data change, then frozen. **No new dependency** — `d3-force` is *not* in `node_modules` (only `commit-graph`, which is git-specific), and the project keeps its dependency list deliberately lean. For < 100 nodes a fixed-iteration integrator is more than adequate and avoids a dep that would need god's approval. Nodes are also **draggable** (drag pins a node; the sim relaxes the rest around it). Layout seeding is deterministic (seeded by node index, not `Math.random`) so the graph doesn't jump between refreshes. +**Implementation:** a tiny hand-rolled simulation (~50 lines: Coulomb repulsion + Hooke spring + centre gravity + velocity damping), run for a fixed number of ticks on data change, then frozen. **No new dependency** — `d3-force` is *not* in `node_modules` (only `commit-graph`, which is git-specific), and the project keeps its dependency list deliberately lean. For < 100 nodes a fixed-iteration integrator is more than adequate and avoids a dep that would need orchestrator's approval. Nodes are also **draggable** (drag pins a node; the sim relaxes the rest around it). Layout seeding is deterministic (seeded by node index, not `Math.random`) so the graph doesn't jump between refreshes. --- @@ -136,7 +136,7 @@ Render the graph as inline SVG inside the panel: ``/`` edges wit The office floor uses Pixi because it is a continuously-animating tilemap game world with a moving camera and dozens of walking sprites — the right tool there. The memory graph is a small, mostly-static, interaction-and-text-heavy data view — SVG is the right tool here. SVG does **not** compromise the pixel aesthetic: square `` nodes, stepped (non-bezier) edges, hard `filter`/duplicate-rect offset shadows, and the VT323/Pixelify fonts keep it visually identical in spirit to the rest of the app. -> **This is the one decision I'd most like god to confirm.** If hive-wide consistency ("everything visual is Pixi") outweighs the engineering simplicity, I'll switch to Pixi — the data model, layout, and interactions in this spec are renderer-agnostic and unchanged either way. My recommendation is SVG. +> **This is the one decision I'd most like orchestrator to confirm.** If hive-wide consistency ("everything visual is Pixi") outweighs the engineering simplicity, I'll switch to Pixi — the data model, layout, and interactions in this spec are renderer-agnostic and unchanged either way. My recommendation is SVG. --- @@ -161,7 +161,7 @@ Tooltips and the navigate-to-memory hook are the two requirements called out in - Container: `PixelPanel` (`variant="inset"`), same `Section`/`Scroll` primitives already used by the other tabs. - Canvas background: `--cth-paper-100`; a faint 32px dotted grid (echoes the tile grid) in `--cth-ink-100`. -- Agent node fill: `--cth-`; border `--cth-ink-900` 2px; offset shadow `2px 2px 0 --cth-ink-900`. God: 1.4× size + double border. +- Agent node fill: `--cth-`; border `--cth-ink-900` 2px; offset shadow `2px 2px 0 --cth-ink-900`. Orchestrator: 1.4× size + double border. - Status: a 1px ring in the `status-` colour (idle/thinking/working/blocked/success) so liveness reads at a glance. - Topic node: `--cth-cream-200` fill, `--cth-ink-700` hairline, VT323 label `--cth-ink-700`. - Edges: message palette per act (mirror `MessageEnvelope.tsx`); topic edges = ink-300 dashed. @@ -185,7 +185,7 @@ src/renderer/src/components/memoryGraph/ Wiring into `CommandCenterPanel.tsx` (per shared-file discipline — additive only): 1. Add `'graph'` to the `CCTab` union. 2. Add one `TABS` entry: `{ key: 'graph', label: 'graph', icon: 'mcp' }` (reuse an existing `IconName` — `mcp` reads as a network/node glyph; no new icon needed). -3. Add `{tab === 'graph' && }` as a **self-contained block** alongside the other `tab === …` lines. No reordering of existing tabs or unrelated reformatting. +3. Add `{tab === 'graph' && }` as a **self-contained block** alongside the other `tab === …` lines. No reordering of existing tabs or unrelated reformatting. The data-loading logic mirrors `ActivityTab` (poll `hiveLog` on a 5s interval) and `MemoryTab` (`hiveMemory(id)` per agent), so it reuses proven patterns. @@ -194,7 +194,7 @@ The data-loading logic mirrors `ActivityTab` (poll `hiveLog` on a 5s interval) a ## 11. Edge cases, performance, non-goals - **Scale:** caps — top 24 topics, 200-entry log window — keep nodes < ~100 and edges < ~200; SVG handles this with no perceptible cost. Any cap that drops data is surfaced in the UI, never silent. -- **Self-loops** (agent messaging itself, or god→god) are dropped. +- **Self-loops** (agent messaging itself, or orchestrator→orchestrator) are dropped. - **Memory fetch cost:** topic extraction needs every agent's memory text. Fetch lazily and cache by id; only refetch when the topics layer is enabled, so the default (agents-only) view does N=0 memory reads beyond what hover needs. - **Stability:** deterministic seeding + pinned dragged nodes → the graph doesn't reshuffle on every poll. - **Worktree caveat:** this runs in the Electron renderer; it cannot be exercised by a full GUI run from the worktree. The Phase 2 bar is a clean `npm run typecheck` + `npm run build` (per dispatch). @@ -202,7 +202,7 @@ The data-loading logic mirrors `ActivityTab` (poll `hiveLog` on a 5s interval) a --- -## 12. Open questions for god +## 12. Open questions for orchestrator 1. **Renderer: SVG vs Pixi** (§7) — I recommend SVG; confirm or tell me to use Pixi for hive-wide visual consistency. 2. **Topic layer default** — I propose default **OFF** (agents+messages is the cleaner first impression). OK? diff --git a/README.md b/README.md index b8cb806686..de4db73942 100644 --- a/README.md +++ b/README.md @@ -1,571 +1,138 @@ -
- -Munder Difflin, the agent harness to run an office of your clones - -# Munder Difflin - -### Agent harness to run an office of your clones - -

- GitHub Trending: #1 Repository of the Day - Munder Difflin, #5 Product of the Day on Product Hunt -

- -The Munder Difflin office floor: agents at desks working in parallel, with the Command Center and a live agent terminal on the right - -**Free, open source and performant.** A multi-agent harness that works with the -subscriptions you already pay for, on their hourly limits. It turns the terminal coding CLI -you already run into a clone of you, one that keeps working while you're away and -coordinates a whole office of agents on your own machine. - -Wraps [Claude Code](https://claude.com/claude-code), Antigravity (Gemini), OpenAI Codex, -**xAI Grok**, **Kimi Code**, **Gemini CLI**, **Qwen**, **OpenCode**, **Crush**, -**pi.dev**, **GitHub Copilot CLI**, and **Cursor**, with bring-your-own keys and local LLMs. -Agents that message, route, and remember, coordinated by **your clone** (Michael) and -visualized as avatars at work on a shared office floor. - -

- Electron · React · TypeScript · Pixi.js · xterm.js · node-pty -

- -

- License: MIT - Version: 0.5.6 - Downloads across all releases - Status: pre-release - Platform: macOS | Windows | Linux - PRs welcome - Blog - Discord -

- -
- - - - -

- -**[⬇ Download 0.5.6 for macOS, Windows or Linux](https://harnessmd.com/download)** - -macOS builds are signed and notarized. You do not need to build from source to use it. -The open source build is on the GitHub releases page. - -**Cancel your Granola and Wispr Flow subscriptions.** It dictates into any app and transcribes your -meetings, all running locally. - -**New here?** Start with the [install guide](https://munderdiffl.in/blog/how-to-install-and-use-munder-difflin/), then -[your first hour](https://munderdiffl.in/blog/your-first-hour-with-munder-difflin/), and see [every agent on our own floor](https://munderdiffl.in/blog/ai-agent-use-cases-our-office/). - -Launch offer: Pro is free for everyone until Saturday 24 October 2026: download the app and sign in. -After that the Pro annual plan is $150 USD, adjusted for purchasing power in different countries and as low as $100 a -year. Get Pro - -
- ---- - -> [!NOTE] -> **The world's best agents. The world's worst paper company.** -> Munder Difflin takes the terminal-agent CLIs you already run (`claude`, `agy`, `codex`, `grok`, -> `kimi`, `qwen`, `opencode`, `crush`, `pi`, and `copilot`) and turns them -> into a self-coordinating team: each agent gets long-term memory, a mailbox, and a desk on a 2D -> office floor, and **your clone** (Michael) routes work between them while you watch. He's the -> boss of the floor; you're still the boss of him. - -## Contents - -- [Supported agents](#supported-agents) -- [What it is](#what-it-is) -- [How it works](#how-it-works) -- [Features](#features) -- [Getting started](#getting-started) -- [Architecture & project structure](./docs/ARCHITECTURE.md) -- [Roadmap](#roadmap) -- [Contributing](#contributing) -- [Telemetry](#telemetry) -- [License](#license) -- [Acknowledgements](#acknowledgements) - -## Supported agents - -**Bring the CLI you already pay for.** Every one of these runs as a real process in its own -terminal, with your existing subscription and its hourly limits. If it runs in a terminal, it -can run here. - -

- Claude Code - Codex · GPT - Grok · xAI - Kimi Code - Gemini CLI - Antigravity · Gemini - Qwen - OpenCode - Crush · Charm - Pi - GitHub Copilot - Cursor - + any custom command -

- -Plus **bring your own keys** and **local models** through Ollama, LM Studio or vLLM. - -## What it is - -Munder Difflin is a desktop app that wraps **real terminal-agent CLIs** as fully-capable agents, -wires them into a **hive mind**, and puts **your clone** in charge. Michael is the one agent *you* -talk to in order to get things done. Under the hood it runs the **fastest memory layer in the -world** so every agent remembers what it learns and recalls it instantly. - -- **Every terminal is an agent.** Each `claude`, `agy`, `codex`, `grok`, `kimi`, `qwen`, `opencode`, `crush`, `pi`, `copilot`, or custom session runs as a real - process in a pseudo-terminal (`node-pty`), byte-for-byte authentic, rendered with xterm.js. -- **Every agent is an avatar.** Sessions appear as characters on a Pixi.js office floor. They walk - to stations as they work, and envelopes fly desk-to-desk when they message each other. -- **The hive coordinates them.** Agents read their memory and drain a mailbox; the router moves - messages between inboxes; the GOD agent adjudicates, assigns, and escalates only when it needs you. -- **Memory that's instant.** A markdown-first memory layer with a semantic recall index means agents - remember across sessions and recall in milliseconds. - -## How it works - -``` - you ── talk to ──► ┌─────────────┐ - │ GOD agent │ orchestrator / supervisor - │ (Michael's │ roster · routing · adjudication - │ office) │ blackboard · task ledger - └──────┬──────┘ - │ assigns · routes · escalates - ┌────────────────────────┼────────────────────────┐ - ▼ ▼ ▼ - ┌───────────┐ ┌───────────┐ ┌───────────┐ - │ agent A │ message │ agent B │ message │ agent C │ - │ provider │ ─────────► │ provider │ ─────────► │ provider │ - │ + memory │ │ + memory │ │ + memory │ - └───────────┘ └───────────┘ └───────────┘ - └──────── shared hive: memory · mailbox · blackboard · log ───────┘ -``` - -1. **You spawn agents**: each is a normal terminal process (`claude`, `agy`, `codex`, or custom) - with its own working directory, identity, and provider-specific lifecycle. -2. **Agents collaborate through the hive**: a local git repo of plain files. They write to their own - `outbox/`; the harness's router delivers into recipients' `inbox/`. No agent ever touches git - (single-committer design avoids `index.lock` corruption). -3. **The GOD agent runs the floor**: it reads every request, resolves routine ones itself (keeping - the system fully autonomous), and only escalates *critical* items (spend, destructive ops, scope - changes) into an approvals queue you act on. -4. **Everything is visible**: you watch avatars move, envelopes fly, and the live terminal stream; - you can type back into any session, browse its files, and read its git history. - -See [`HIVE.md`](./HIVE.md) for the full multi-agent design, [`SPEC.md`](./SPEC.md) for the -terminal/event plane, and [`DESIGN.md`](./DESIGN.md) for the visual system. - -## Features - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -### Cancel your Granola and Wispr Flow subscriptions - -**Dictate into any app.** Hold Option on the Mac, or Control+Alt+Space on Windows and Linux (X11), -talk, and let go. The words land in whatever field is in front of you, and in every text box inside -Munder Difflin. The message box mic works in the free build too. - -**Meetings hear both sides**, in Stapler, part of Pro. Your microphone is written as You and the -call's audio as Them, on Mac, Windows and Linux. Shift+Command+Space starts and stops a meeting on -the Mac, Control+Shift+Space on Windows and Linux. Correct the transcript, add a description, and -send it to any of your agents. - -**Transcription on your machine.** A Whisper model ships inside the app and is the default engine. -A larger model is one download away, Apple's on device recogniser works on macOS 26, and Groq stays -off unless you add a key. 126 built in technical names plus your own words. English for now. - -Stapler, the yellow puck, with its ring open and Record meeting ready - - - -### One row per agent, and it tells you everything - -Classic, the free skin, gets the new agent cards: status, engine, model, a context gauge, the -ticket it is on and the action it is taking right now, a crash strip instead of a silent -terminal, and a right click menu for the common jobs. - -Pro gets the new sidebar: Tasks, Inbox, Automations, Memory, Capabilities and the Stapler at the -top, agents grouped under their projects, a live line per agent, **Asked you** on the row when an -agent has a question, notes you can search, and **Keep my agent order** to drag agents and projects -where you want them. Drag the sidebar's edge to make it wider, hover a ticket for its details, or -switch to agents and notes only. - -
- -### Talk to one agent, not twelve - -Michael is your clone and the only agent you brief. He assigns the work, routes the traffic, and -escalates the few things that actually need you. - - - Briefing Michael, the orchestrator agent, from the Command Center -
- -### Hire an agent in a few clicks - -Pick the CLI, the model and the autonomy, give it a desk, and it starts working. Import a -ready made role from the [Agent Gallery](https://munderdiffl.in/hires/) if you would rather not -start from scratch. - - - The add agent dialog: choosing a provider, model and role -
- -### Memory that survives the session - -Every agent keeps markdown memory that is mined into a shared, searchable palace. Close the app, -come back tomorrow, and they still know what they learned. - - - Searching the shared memory palace across every agent -
- -### Autonomy with a leash - -Set how far each agent may go on its own. Spend, scope and destructive operations come back to -you, and a circuit breaker steers, constrains, then stops anything that loops or runs away. - - - Per agent autonomy and approval settings -
- -### Watch the whole floor work - -Agents walk to stations as they work and envelopes fly desk to desk when they message each other. -Click any desk to read that terminal live, and type straight back into it. - - - Agents working in parallel on the office floor -
- -### Set up once - -The onboarding wizard checks what you already have, and offers to install what is missing rather -than sending you to a docs page. - - - The first run setup wizard -
- -**The floor** -- **Every terminal is a real agent.** Claude Code, Antigravity (Gemini), OpenAI Codex, xAI Grok, Kimi Code, Gemini CLI, Qwen, OpenCode, Crush, pi.dev, GitHub Copilot CLI, Cursor, or a custom command, each in its own `node-pty` PTY, rendered with xterm.js. -- **Every agent is an avatar.** A Pixi.js office floor where agents walk to stations, envelopes fly desk to desk, and avatar state reflects real work. -- **A GOD orchestrator you talk to.** It routes tasks, adjudicates traffic, and escalates only what needs a human. Or press **Talk** and run the floor by voice. -- **Per-agent git worktrees.** Optional isolation so parallel agents never collide on branches. -- **More than one floor** (0.5.3). File, New Floor opens another office on its own folder, in its own window, with its own agents, board and memory. A folder only ever opens in one floor, and one licence covers them all. - -**Memory & coordination** -- **The hive**: per-agent memory, atomic-file mailboxes, a shared blackboard, an append-only event log, single-committer git. -- **Semantic recall**: markdown memory mined into a shared palace, searchable from the UI, with condensation so it doesn't grow forever. -- **Enterprise Knowledge Graph**: your own documents and policies, queryable by any agent. - -**Control & safety** -- **Human gates**: spend, scope, and destructive ops escalate to you. Steer mid-run or stop gracefully. -- **Circuit breaker**: a steer → constrain → stop ladder for agents that loop, storm errors, or blow their budget. -- **Budgets & telemetry**: per-agent token budgets, real cost from transcripts, a durable ledger, OTel spans, and a tool waterfall. - -**Command Center** -- **Tickets with keys** (0.5.3): every card gets a key like V53-299, each agent has a Tasks tab with what it did and when, and the Tasks screen filters by date. -- Kanban tasks with dependencies, scheduled missions + heartbeat, live fleet monitoring, memory search, activity log, and a CI watcher. -- **Skills**: what every agent can already do across Claude Code, OpenCode and Codex, plus a browsable catalog of 227 more with search, filters, install and uninstall. -- **Built-in Monaco IDE**: file tree, editor tabs, save, plus CHANGES · HISTORY · COMPARE git rails with commit graph, diffs, branch compare, and guarded checkout. All fs/git access brokered through main. - -**Getting work in and out** -- **Slack & webhooks**: message a channel or POST a webhook; Michael can spawn an ephemeral worker, reply in-thread, and tear it down. -- **Shareable hires + Agent Gallery**: import a role from a `munderdifflin://hire` link; import only pre-fills the form, a human still spawns it. Browse roles at the [Agent Gallery](https://munderdiffl.in/hires/). -- **Local transcription and dictation** (0.5.3): dictate into any app (Option on the Mac, - Control+Alt+Space on Windows and Linux X11), and Stapler in Pro transcribes meetings with You and - Them on Mac, Windows and Linux. A bundled Whisper model is the default engine; Apple's on device - recogniser on macOS 26 and Groq with your own key are options. A built in vocabulary of 126 - technical names keeps Anthropic, Claude, Kubernetes and ARR spelled right, and you can add your own. -- **Tell an agent from the Stapler** (Pro): Record message, several screenshots per message, PDFs, - videos and folders, and a To menu that remembers who you picked. -- **BYOK keys + local LLMs**: per-provider keys in a write-only secret broker, plus Ollama / LM Studio / vLLM base URLs. Guides: [open models](https://munderdiffl.in/blog/run-munder-difflin-on-open-models/) · [Mac Mini](https://munderdiffl.in/blog/run-munder-difflin-on-a-mac-mini/). -- **Updates in one click**: the title-bar badge runs the real update. It downloads the build for your machine, then restarts and installs it, and it reads `latest` once a check confirms you are current. A manual download is the fallback for when the updater cannot fetch the build itself. The first run afterwards opens that release's notes as a designed page rather than a version number. -- **Your language**: English, Simplified Chinese and Arabic, with right to left layout for Arabic. English is the default and nothing changes until you pick another one in Settings. The app does not read your OS locale. All three app fonts ship inside the bundle, so nothing is fetched at boot. -- **Prerequisites**: one Settings page showing which supporting tools (uv, git, Node, MemPalace, each agent CLI) you have, what each is for, and a button that asks Michael to install what is missing. - -> [!NOTE] -> **Status: v0.5.6. Capabilities, Integrations and Teams, and fewer tokens.** -> **Pro is free for everyone until Saturday 24 October 2026**, as part of the launch: download the app and sign in. -> These notes describe the 0.5.6 app on the [download page](https://harnessmd.com/download). -> **Capabilities (Pro)**: a marketplace of 2,347 MCP servers, skills, plugins and tools from the public list -> [HarnessMD/awesome-agent-capabilities](https://github.com/HarnessMD/awesome-agent-capabilities). We reviewed 622 of -> them; the rest read "Listed, not reviewed by us" and have no Install button. A merged change reaches the app with no -> new release, and the app uses the list only when it carries our signature. -> **Integrations**: one place for Slack, webhooks, the apps your agents use and your keys, each with a plain status and -> the last 50 things that happened. Nothing saves until you press Save. -> **Teams**: your office talks to your teammates' offices. One plain chat per teammate, you choose who answers each one, -> and files and folders go as a link with a password, for one download. -> **Fewer tokens by default**: Compact when context fills and Clear on a switch of work are on for every new install. -> The clear runs when an agent's work switches after a session of 60 minutes, and a `handoff.md` in the agent's folder -> carries the thread across. A new install has no hourly standup. In a replay of one office of 13 Claude agents over a -> week, with those two changes, token use fell about 37%. That is a replay, not a live measurement. -> **Agent files (Pro)**: export an agent as a file and choose what goes in; a key, a token or a secret's value never -> does. 36 templates replace role bundles. -> **Temps**: any lasting agent can start a temp, a short lived agent for one job, not only the orchestrator. Wherever you -> pick who answers (Slack, a teammate, a webhook, a schedule's Sends to, the Stapler's Send captures to), you can pick -> **A temp** or **A temp, clone of** one of your agents. **Temps per agent** (0 to 4) and **Max temps at once** sit on -> the orchestrator's **Budget & breaker** tab. -> **Slack**: one bot answers in **All channels** it is in by default, or only the channels you tick, and in direct -> messages; replies stay in the thread. Set the Slack app up from the manifest: press **Copy manifest** under -> **How to get these**. Slack comes back by itself after a failed start. -> **Token limits per period**: Every 5 hours, Daily, Weekly or Monthly, for the office and for each agent. They replace -> the old token caps. -> **Sign in and accounts in the app**: press **Open browser to sign in** and sign in there; the screen moves on by itself, -> or you paste the code into **Code from the browser**. **Sign out** and **Switch to Teams** sit in the profile menu. -> **Circuit breaker**: an agent that stopped as told is no longer constrained again, and the same warning is not sent twice -> inside ten minutes. -> **Fixes**: the window no longer hangs after hours, hooks work in Claude Code's auto mode, sign in works on a network with -> its own certificate, a message to Codex no longer waits for Enter, the Windows folder name field shows the name and not -> the whole path, the Dock click works the first time with the Stapler on, and Haiku 5.5 is in the model list. -> New to it? The [install guide](https://munderdiffl.in/blog/how-to-install-and-use-munder-difflin/) and [your first hour](https://munderdiffl.in/blog/your-first-hour-with-munder-difflin/) cover every step. -> -> **v0.5.3: Cancel your Granola and Wispr Flow subscriptions.** -> The Stapler does both, all running locally, and every agent gets easier to start, restart and steer. -> **Dictate into any app**: hold Option on the Mac (Microphone and Accessibility permissions), or -> Control+Alt+Space on Windows and Linux X11, talk, and let go. Wayland says why it cannot. -> **Meetings hear both sides** in Stapler, part of Pro: You and Them, on Mac, Windows and Linux, -> transcribed locally, then corrected, described and sent to any of your agents. -> **Transcription on your machine**: a bundled Whisper model by default, an optional 190 MB larger -> model, Apple's on device recogniser on macOS 26, and Groq only if you add a key. -> 0.5.3 transcribes English. The bundled model is English only; on macOS 26 dictation runs on Apple's engine, which follows the Mac's language and we have tested it in English. Other languages arrive as a model download in a later release. -> **A missing CLI is a card, not an error**: install and sign in from inside the app for every -> engine, with Set up manually when either fails. **Ask me** on every agent's Inbox, answered from -> one box over the composer. **More than one floor**: File, New Floor (Shift+Command+N, or Control+Shift+N) runs another -> office on its own folder, with its own agents, board and memory, and one licence covers every floor. **Send now** goes straight in, **Restart & Continue** -> keeps the conversation for every engine, and **Opus 5.5** is the default Claude model. -> **Settings, redesigned**: one Save for everything, inbound webhooks for GitHub, Linear, Telegram -> and your own, each with the agent that answers it, and custom secrets stored encrypted beside -> your provider keys. **Crashes keep your work**: a crashed agent keeps its uncommitted work. The IDE opens on Command+I with find and replace, the sidebar's reorder handle -> sits on the right in Arabic, and code in the IDE reads left to right in every language. -> Automatic compaction runs every 40 minutes at 30 percent of the context window, the same bar on -> every model size, where the previous default was every two hours at 60 percent. -> **Tasks you can follow**: every ticket gets a three letter key like V53-299, taken from the hive -> folder's name or set in Settings, Autonomy. Each agent has a **Tasks** tab beside Inbox and -> Terminal with what it did and when, the Tasks screen filters by Today, This week, This month or -> All time, and a card drops anywhere in a column. Hover a ticket in the sidebar for its details. -> **The Pro sidebar** is as wide as you drag it, and one setting shows only agents and notes. With -> more than one floor open, each window and Dock icon carries the office's number. -> **Stapler fixes**: the mic stops at the click, no words are lost at a pause, Record message never -> gets stuck, the drag follows your pointer, Reset puts it in the middle of the screen, and it never -> ends up off screen. -> Fixes: a restarted agent's terminal takes the mouse again, the hive repository no longer swallows -> agent git folders into its object database and the disk space comes back, and one hook event now -> renders one sidebar row with the task ledger read once. -> Not in this release: the Kimi scroll and queue bug, which we could not reproduce, and it is first -> on the 0.5.4 list. -> Before that, v0.4.6 brought a Simplified Chinese and Arabic interface with right to left support -> and self hosted fonts, the update badge that runs the real download and restart, and the security -> work on CLI name validation and analytics. -> **If you're on 0.3.8, update:** that build's usage-limit guard never released the agents it held, -> and it has been removed entirely. -> macOS (signed & notarized), Windows, and Linux builds are on the -> [download page](https://harnessmd.com/download). The open source build is on the -> [releases page](https://github.com/HarnessMD/munder-difflin/releases). - - - -## Getting started - -### Download the app - -**Most people want this one.** Munder Difflin is free and open source. Signed and notarized macOS -builds of 0.5.6, plus Windows and Linux, are on the [download page](https://harnessmd.com/download). -The open source build is on the [GitHub releases page](https://github.com/HarnessMD/munder-difflin/releases). Install it, -open it, and the wizard takes you the rest of the way. The [install guide](https://munderdiffl.in/blog/how-to-install-and-use-munder-difflin/) walks through -every step and every fix. You do not need Node, a toolchain, or this -repository. - -You do still need at least one agent CLI on your machine. From 0.5.3 a missing CLI shows as a card -in the agent's terminal: Install runs the command, you sign in inside the app, and the agent starts. -**Settings → Prerequisites** lists the rest. - -**Pro** adds Capabilities, agent files, Stapler and the new sidebar. As part of the launch it is free for everyone -until Saturday 24 October 2026. After that the annual plan is $150 USD, adjusted for purchasing power, as low as -$100 a year. -[Get Pro](https://app.harnessmd.com/console/license) - -### Build from source - -Everything below is for contributors and for people who want to run an unreleased build. - -### Prerequisites - -- **macOS, Windows, or Linux**. -- **Node.js 18+** and npm. -- A **C/C++ toolchain** for `node-pty`'s native addon. On macOS, install Xcode Command Line Tools: - ```bash - xcode-select --install - ``` -- At least one supported agent CLI on your `PATH`, one of **[Claude Code](https://claude.com/claude-code)** - (`claude`, the default), **Antigravity** (`agy`), **OpenAI Codex** (`codex`), **xAI Grok** (`grok`), - **Kimi Code** (`kimi`), **Gemini CLI** (`gemini`), **Qwen** (`qwen`), **OpenCode** (`opencode`), - **Crush** (`crush`), **pi.dev** (`pi`), **GitHub Copilot** (`copilot`), or **Cursor** (`cursor-agent`). - Most missing CLIs self-heal: the harness runs the installer in the - terminal and continues into the new binary. -- *Optional:* **your own API keys and local LLMs** in **Settings → AI Engines** (Ollama / LM Studio / vLLM). -- *Optional:* the semantic memory index for instant cross-session recall. Markdown memory works without it. - -### Install & run +# agent-stage + +**A local-first stage for a team of terminal coding agents.** Each `claude`, `codex`, `gemini`, +`agy`, `grok`, `kimi`, `qwen`, `opencode`, `crush`, `pi`, `copilot` or `cursor-agent` session runs as +a real process in its own pseudo-terminal. A file-based **hive** lets them message each other, and +an **orchestrator** agent is the one you brief. You watch the work happen as characters at desks on +a pixel office floor. + +agent-stage is a fork of [munder-difflin](https://github.com/chaitanyagiri/munder-difflin) by +Chaitanya Giri (MIT). It keeps the engine and changes the surface: a neutral default theme with no +third-party art, a theme system you can extend, a usage-aware standup, and the orchestrator naming +used throughout the code. + +## What you get + +- **Every terminal is an agent.** Real CLIs under `node-pty`, rendered with xterm.js. Nothing is + emulated. +- **An orchestrator you talk to.** It assigns work, routes mail between agents and escalates only + when it needs you. You can rename it; a theme can suggest a name. See + [Naming the orchestrator](#naming-the-orchestrator). +- **A hive on disk.** `registry.json`, per-agent `inbox`/`outbox`, a task ledger and an append-only + `log.jsonl`. Plain files, easy to inspect and back up. +- **A pixel office.** Agents walk to desks as they work and envelopes travel between them. +- **Themes.** The bundled **plain** theme is generated from flat shapes. Drop in your own packs: + see [Themes](#themes). +- **Standups with usage awareness.** An hourly ops standup can pull your own + *"how many tokens does each agent have left"* endpoint so the orchestrator can reroute away from + agents near their limit, or spend capacity that is about to reset. See + [Usage endpoint](#usage-endpoint). +- **Bring your own keys and local models** (Ollama, LM Studio, vLLM) in Settings → AI Engines. + +## Run it + +Requirements: macOS, Windows or Linux; Node.js 18+; a C/C++ toolchain for `node-pty`; and at least +one agent CLI on your `PATH`. **Settings → Prerequisites** shows what is missing. ```bash -git clone https://github.com/chaitanyagiri/munder-difflin.git -cd munder-difflin +git clone https://github.com/jdell64/agent-stage.git +cd agent-stage npm install # postinstall rebuilds node-pty against Electron's ABI -npm run dev # launches the Electron app with hot reload +npm run dev ``` -On first launch you'll go through the onboarding wizard, then land on the floor. Use **Add agent** to -spawn your first session. The GOD agent seats itself in Michael's office automatically. - -### Other scripts +Complete the onboarding wizard, then **Add agent** to start a session. The orchestrator seats itself +at the first desk. ```bash -npm run build # production build via electron-vite -npm run preview # preview the production build -npm run typecheck # type-check the node (main/preload) and web (renderer) projects +npm run build # production build +npm run typecheck # main/preload and renderer projects +npm run test:focused # node:test suites in test/ +npm run test:themes # vitest: theme-pack validator and the bundled pack ``` -> If `node-pty` fails to load after an Electron upgrade, re-run `npm install` (the `postinstall` hook -> runs `electron-rebuild` against the current Electron ABI). - -## Architecture - -Two data planes feed one renderer: a **terminal plane** that owns the PTYs, the filesystem and git, -and an **event plane** that runs the hive, the hook server and the router. The renderer talks to -both only through a typed bridge. +If `node-pty` fails to load after an Electron upgrade, re-run `npm install`. -**The full diagrams, the module by module project structure, and the design system live in -[`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md).** They moved out of this file so it can explain -the product rather than the codebase. Also see [`HIVE.md`](./HIVE.md) for the multi-agent design, -[`SPEC.md`](./SPEC.md) for the terminal and event plane, and [`DESIGN.md`](./DESIGN.md) for the -visual system. +## How it works - +``` + you ── brief ──► ┌──────────────┐ roster · routing · adjudication + │ orchestrator │ task ledger · blackboard + └──────┬───────┘ + inbox/outbox │ hive router + ┌──────────────────┼──────────────────┐ + ┌────▼────┐ ┌────▼────┐ ┌────▼────┐ + │ agent A │ │ agent B │ │ agent C │ each: a CLI in a PTY + └─────────┘ └─────────┘ └─────────┘ +``` -## Roadmap +Agents read their instructions, drain their inbox, and write replies to their outbox. The router +moves messages between inboxes. The orchestrator id is `orchestrator`; older hives that used a +different id are migrated on first start (the registry is backed up first). -Shipped through **v0.5.6**: Capabilities, Integrations and Teams, agent files with 36 templates, token limits per -period, fewer tokens by default with a handoff file across a cleared session, temps that any -agent can start and that can answer inbound messages, one Slack bot for every channel it is in, sign in and accounts -inside the app, a quieter circuit breaker, local dictation into any app and meetings with both sides of the call, -with the model in the installer, the new agent cards in the free build and a new Pro sidebar, a -missing CLI you install and sign in to from inside the app, Ask me on every Inbox, more than one -floor on one machine, ticket keys and a Tasks tab on every agent, redesigned Settings with inbound webhooks and custom secrets, Opus 5.5 as the default Claude -model, one compaction bar for every model size, a Simplified Chinese and Arabic interface with right to left -support -and self-hosted fonts, twelve agent engines with BYOK keys and local LLMs, voice orchestration, -the hive (memory · mailboxes · blackboard · event log), Command Center with kanban and weekday -schedules, a built-in Monaco IDE with git rails, integrations registry + secret broker, -Slack-spawned workers, shareable hires and the Agent Gallery, observability and the circuit -breaker, durable persistence, session resume, multi-window floors, one click updates, a Skills -browser, a live Prerequisites check, cost reporting folded from the ledger, semantic memory -that works on Apple Silicon, and an updater that installs the build instead of pointing at it. -Full history in [`CHANGELOG.md`](./CHANGELOG.md). +## Themes -Next up: +A theme pack is a folder: a `theme.json` manifest, a Tiled `map.tmj`, and 16 px tilesets, with +optional fonts, UI tokens and a cast. The only pack in this repository is `plain`. -- [ ] **More chat integrations**: Telegram and richer chat bridges that pipe a channel into Michael's queue and route replies back out. -- [ ] **More engines & integration templates**: keep growing the engine roster and the integrations registry. -- [ ] **Fuller avatar coverage**: drive the remaining station visits and tool-bubbles entirely from real hook events. -- [ ] **Durable layout & command history**: extend persistence to agent layout and per-session history. +```bash +npm run theme:new -- my-office # copy `plain` to ./my-themes/my-office +npm run theme:validate -- my-themes/my-office +MD_THEMES_DIR=./my-themes npm run dev # or Settings → Office theme → Extra themes folder +``` - +Settings → Office theme lists every pack, shows exactly what is wrong with a broken one, and has +**Rescan** and **Open themes folder** buttons. The full contract is in +[docs/theming/THEMING.md](docs/theming/THEMING.md). -## Contributing +Third-party packs (for example art you bought, or a TV show's likeness) are yours to keep outside +this repository. `resources/themes/*` is gitignored except `plain`. -Contributions are welcome. This is pre-release software with a lot of surface area. Start with -[`CONTRIBUTING.md`](./CONTRIBUTING.md). The short version: fork, `npm install && npm run dev`, keep -`npm run typecheck` green, and **derive any new UI from [`DESIGN.md`](./DESIGN.md) tokens**. Good -first areas: wiring real hook events, the add-agent flow, the config drawer, and cross-platform work. +### Naming the orchestrator -> [!IMPORTANT] -> **Every pull request must show a before and an after**: screenshots, or a recording when the -> thing moves, under the `### Before` and `### After` headings in the PR template. This is checked -> automatically and a PR without it does not merge. "My change has no UI" is not an exemption; it -> just changes what the evidence looks like. See -> [Evidence is mandatory](./CONTRIBUTING.md#evidence-is-mandatory). +Code and agent protocol always say *orchestrator*. The label people see resolves in this order: +your own rename, then the active theme's `copy.orchestratorName`, then `Orchestrator`. -Questions, bugs, or want to show off your office? Join the Discord: ****. Add your Discord handle to a PR and you'll get the `employee of the month` role when it merges. +## Usage endpoint -**Looking for somewhere to start?** The -[`good first issue`](https://github.com/chaitanyagiri/munder-difflin/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) -list is kept stocked with small, self contained work that has a clear finish line. +Point agent-stage at any HTTP endpoint that returns per-agent remaining capacity, and the hourly +standup will include a summary and write `usage-availability.json` into the hive for the +orchestrator to read. Settings → Autonomy & Budgets has the URL, optional bearer token, timeout, and +a **Test** button. Failure never blocks a standup. The response format, a ~30-line reference server +and security notes are in [docs/usage-endpoint.md](docs/usage-endpoint.md). -**Everyone whose code is in Munder Difflin is listed in [`CONTRIBUTORS.md`](./CONTRIBUTORS.md).** -If that is you, it is yours to point at. The list is generated from the pull requests themselves and -updates on its own, so you appear without having to ask. It also names the contributions that are in -`main` but that GitHub shows as closed rather than merged, because that was our mistake to record -and not theirs to explain. +## Models - - Munder Difflin contributors - +The model list ships in `src/shared/modelCatalog.json` (including Gemini 4 Argon for Antigravity +and the Gemini CLI) and is refreshed at runtime from `docs/model-catalog.json` on this repository's +`main` branch, so edit that file to add a model without a release. -## Telemetry +## Layout -Official builds send a **small set of anonymous usage events** (app opened, agent spawned, feature -used), never prompts, code, file paths, or agent output. The complete event list, the anonymity -guarantees, and the three ways to opt out (Settings toggle, `DO_NOT_TRACK`, or building from -source, forks compile with no key and send nothing) are documented in -[`TELEMETRY.md`](./TELEMETRY.md). +| Path | What lives there | +| --- | --- | +| `src/main` | Electron main process: hive, router, PTYs, missions/standups, config | +| `src/renderer/src` | React UI, Pixi office scene, i18n | +| `src/shared` | Code shared by both: theme-pack validator, identity, usage endpoint | +| `resources/themes/plain` | The built-in theme (generated by `scripts/theme-packs/plain/gen_pack.py`) | +| `docs/` | `ARCHITECTURE.md`, `DESIGN.md`, `theming/`, `usage-endpoint.md` | +| `test/` | `node:test` suites and vitest theme tests | -## License +## License and credit -> [!IMPORTANT] -> **Asset licensing.** The bundled pixel art (tilesets and maps) is **Modern Interiors - RPG Tileset -> [16X16]** by [LimeZu](https://limezu.itch.io/moderninteriors), used under the **Complete Version -> licence**, which permits editing and use in commercial and non-commercial projects. **Credit to -> 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). +Code: MIT, see [LICENSE](LICENSE); the copyright notice of the original author is retained. The +bundled `plain` theme is original and covered by the same licence ([LICENSE-ASSETS](LICENSE-ASSETS)). -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 -[`LICENSE-ASSETS`](./LICENSE-ASSETS). *Munder Difflin* is an affectionate parody and is not affiliated with NBC's *The Office* or -Dunder Mifflin. +### Acknowledgements -## Acknowledgements +- **[chaitanyagiri/munder-difflin](https://github.com/chaitanyagiri/munder-difflin)** — + the original project and concept this work is built upon. +- **[Prozzta/guppyos](https://github.com/Prozzta/guppyos)** by **Prozzta** — + special thanks and credit to Prozzta for architectural optimizations and key stability fixes, + including: + - Startup token burn reduction (bounded orchestrator handoff for fresh starts vs. resuming giant contexts). + - Standup zero-token delta gating (`standupDelta.ts`), skipping redundant standup turns when the floor is unchanged. + - Billed-equivalent token metrics accounting for prompt cache weights. + - MemPalace process leak hardening and async non-blocking execution. + - Star Trek TOS theme pack inspiration and bridge concepts. -- [LimeZu](https://limezu.itch.io/) for the *Modern Interiors* pixel-art tilesets (Complete Version licence). -- [`shahar061/the-office`](https://github.com/shahar061/the-office) for the office tileset/map vendoring. -- [Pixi.js](https://pixijs.com/) · [xterm.js](https://xtermjs.org/) · [node-pty](https://github.com/microsoft/node-pty) · [electron-vite](https://electron-vite.org/) · [CodeMirror](https://codemirror.net/) for the libraries this is built on. -- [Remotion](https://www.remotion.dev/) for the landing page's animated "how it works" clips (`landing-remotion/`). -- *The Office* (US) for Munder Difflin, Inc. diff --git a/RELEASE.md b/RELEASE.md index 8222cb172f..c5fd291bb9 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -201,9 +201,9 @@ open-source project should build it: ## Still new in 0.4.1 — *The app says what the site says* **Michael is your clone.** The website has been describing Munder Difflin as a clone of you that -works around the clock — the app still called it a "GOD agent." Now they match. +works around the clock — the app still called it a "ORCHESTRATOR agent." Now they match. -- **Your clone, not the GOD agent.** Michael is described as your clone throughout onboarding, +- **Your clone, not the ORCHESTRATOR agent.** Michael is described as your clone throughout onboarding, and his card on the floor carries a **BOSS** tag — he's the boss of the agents, you're still the boss of him. - **Onboarding was rewritten.** It opens on what you actually get ("a clone of you, working @@ -212,7 +212,7 @@ works around the clock — the app still called it a "GOD agent." Now they match are all named. > [!NOTE] -> **This release changes wording only.** The `god` agent id, the hive folder layout, and message +> **This release changes wording only.** The `orchestrator` agent id, the hive folder layout, and message > routing are untouched, so existing hives, memory, and running agents carry over exactly as they > are. Nothing to migrate. @@ -247,7 +247,7 @@ works around the clock — the app still called it a "GOD agent." Now they match [@gts-47](https://github.com/gts-47) and [@qschmick](https://github.com/qschmick). - **0.3.3** — the built-in Monaco IDE, and GitHub Copilot CLI as the first community-contributed engine ([@anxkhn](https://github.com/anxkhn)). -- **0.3.2** — Realtime Michael: a voice channel to the GOD orchestrator. +- **0.3.2** — Realtime Michael: a voice channel to the orchestrator. - **0.3.1** — three more engines: OpenCode, Crush, and pi.dev. Full history in the [CHANGELOG](https://github.com/chaitanyagiri/munder-difflin/blob/main/CHANGELOG.md). @@ -269,7 +269,7 @@ This release carries community work. All 23 of these landed in v0.4.5: | [#175](https://github.com/chaitanyagiri/munder-difflin/pull/175) | [@rekcilyssup](https://github.com/rekcilyssup) | a main-process watchdog wakes an idle worker sitting on an undrained inbox | | [#176](https://github.com/chaitanyagiri/munder-difflin/pull/176) | [@FenjuFu](https://github.com/FenjuFu) | Gemini CLI joins the engine list | | [#177](https://github.com/chaitanyagiri/munder-difflin/pull/177) | [@TTAWDTT](https://github.com/TTAWDTT) | each agent's live context-window occupancy shows in the roster | -| [#178](https://github.com/chaitanyagiri/munder-difflin/pull/178) | [@gpechieu](https://github.com/gpechieu) | a god-hired worker gets a floor card, and it archives when the worker dies | +| [#178](https://github.com/chaitanyagiri/munder-difflin/pull/178) | [@gpechieu](https://github.com/gpechieu) | a orchestrator-hired worker gets a floor card, and it archives when the worker dies | | [#179](https://github.com/chaitanyagiri/munder-difflin/pull/179) | [@kdahal7](https://github.com/kdahal7) | `statAbs` expands `~`, so a path resolves the same way on every platform | | [#181](https://github.com/chaitanyagiri/munder-difflin/pull/181) | [@TTAWDTT](https://github.com/TTAWDTT) | webhook dispatch goes through an atomic add, so a stale ledger cannot overwrite it | | [#184](https://github.com/chaitanyagiri/munder-difflin/pull/184) | [@TTAWDTT](https://github.com/TTAWDTT) | the per-agent steer queue is capped, which bounds memory on a stalled agent | @@ -359,10 +359,10 @@ To produce installers yourself: `npm run dist` (current OS), or `dist:mac` / `di ## What's inside - **The simulation** — every agent is a real `claude` (or `agy` / `codex` / local-provider) pseudo-terminal, visualized as an avatar on a watchable office floor (`node-pty` · `xterm.js` · Pixi.js). -- **Talk to Michael** — a realtime **voice channel to the GOD orchestrator** that reads the hive and acts behind spoken echo-back confirmation, BYOK and main-only. +- **Talk to Michael** — a realtime **voice channel to the orchestrator** that reads the hive and acts behind spoken echo-back confirmation, BYOK and main-only. - **Selectable engines + per-hire capabilities** — each hire (and Michael himself) runs on a pluggable engine, with its own consented skills + MCP catalog. - **MemPalace** — a markdown-first, semantic memory layer the whole office shares; cross-session recall in ~12ms. -- **GOD orchestrator + hive** — one agent you talk to routes work to specialists and stays autonomous, escalating only critical items (spend, destructive ops, scope) to you natively, through human-in-the-loop prompts. It can also spawn an ephemeral worker straight from Slack and tear it down safely. +- **orchestrator + hive** — one agent you talk to routes work to specialists and stays autonomous, escalating only critical items (spend, destructive ops, scope) to you natively, through human-in-the-loop prompts. It can also spawn an ephemeral worker straight from Slack and tear it down safely. - **Plugs into your setup** — your subscription, settings, skills, and MCP servers, plus an integrations registry with a write-only secret broker; `/remote-control` reaches the whole floor from your phone. Full notes in the [CHANGELOG](https://github.com/chaitanyagiri/munder-difflin/blob/main/CHANGELOG.md). diff --git a/SECURITY.md b/SECURITY.md index b0755d0f3c..19493dcfaa 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,37 +2,35 @@ ## Scope -Munder Difflin is a **local-first desktop app**. It spawns local processes in PTYs and -reads/writes files under directories you register. It opens **no network listeners -beyond a local Unix domain socket** used for the in-app hook server, and has no auth or -remote surface by design. +Agent Stage is a local-first desktop app. It starts local processes in PTYs and +reads and writes files under directories you register. It has no public network +listener; its hook server uses a local Unix-domain socket. + +Running Agent Stage can execute commands and affect files on your machine. You +are responsible for deciding whether it is safe for your environment, data, +credentials, projects, and workflows. Use it at your own risk. + +The software is provided without warranty. The project owner and contributors +accept no responsibility or liability for damage, loss, or other consequences +of using it. ## Supported versions -This is an early prototype. Security fixes target the `main` branch only. +Security fixes target the `main` branch only. | Version | Supported | -|---|---| -| `main` | ✅ | -| older tags | ❌ | +| --- | --- | +| `main` | Yes | +| older tags | No | ## Reporting a vulnerability -Please **do not** open a public issue for security problems. - -- Use GitHub's **private vulnerability reporting**: the *Security → Report a - vulnerability* tab on https://github.com/chaitanyagiri/munder-difflin, **or** -- Email **girichaitanya11@gmail.com** with a description, reproduction steps, and - impact. - -You can expect an acknowledgement within a few days. Once a fix is available we'll -credit you (unless you prefer to stay anonymous). +Open a public GitHub issue or submit a pull request with the vulnerability, +impact, and reproduction details. Do not include secrets, private credentials, +or sensitive user data in a public report. ## Notes for reviewers -- Renderer ↔ main IPC goes through a typed `contextBridge` (`window.cth`); the renderer - has no direct Node access (`nodeIntegration: false`, `contextIsolation: true`). -- All `fs:*` / `git:*` IPC calls are sandboxed and path-validated in the main process, - rooted at an agent's working directory. -- The hive commits to a local git repo from a **single committer** (the main process); - agents only write plain files. +- Renderer ↔ main IPC goes through a typed `contextBridge` (`window.cth`); the renderer has no direct Node access (`nodeIntegration: false`, `contextIsolation: true`). +- All `fs:*` / `git:*` IPC calls are sandboxed and path-validated in the main process, rooted at an agent's working directory. +- The hive commits to a local git repo from a single committer (the main process); agents only write plain files. diff --git a/build/agent-stage-icon.md b/build/agent-stage-icon.md new file mode 100644 index 0000000000..7b68c25151 --- /dev/null +++ b/build/agent-stage-icon.md @@ -0,0 +1,22 @@ +# Agent Stage app icon + +`agent-stage.png` is the generated master: three terminal agents on an amber +stage, with teal screens and a dark pixel silhouette. It was created using the +built-in image generation tool for this project. + +The main window uses the PNG in development and loads its packaged copy from +the resources directory. Electron Builder uses `agent-stage.ico` on Windows, +`agent-stage.icns` on macOS, and the PNG on Linux. Restart the development app +to load the new window icon; executable and installer icons require repackaging. + +Regenerate the icon containers on Windows after replacing the master: + +```powershell +powershell -File tools/export-agent-stage-icon.ps1 +``` + +Generation prompt: A polished desktop app icon for Agent Stage, where teams of +coding agents collaborate on a pixel office stage. Three terminal monitors on +a stepped stage, central monitor taller, cream terminal chevrons, dark navy +tile with stepped corners, amber stage, teal screens. Chunky SNES aesthetic, +strong silhouette, square composition, transparent exterior, no lettering. diff --git a/build/agent-stage.icns b/build/agent-stage.icns new file mode 100644 index 0000000000..e058e29314 Binary files /dev/null and b/build/agent-stage.icns differ diff --git a/build/agent-stage.ico b/build/agent-stage.ico new file mode 100644 index 0000000000..e8d8c2932d Binary files /dev/null and b/build/agent-stage.ico differ diff --git a/build/agent-stage.png b/build/agent-stage.png new file mode 100644 index 0000000000..6fd3f82258 Binary files /dev/null and b/build/agent-stage.png differ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eb380e76a6..9d27800f1b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -20,7 +20,7 @@ Two data planes feed one renderer: ┌──────┴──────────┐ ┌──────┴─────────────┐ │ Event Plane │ │ Terminal Plane │ │ hooks / hive │ │ node-pty PTYs │ - │ router + GOD │ │ + fs + git │ + │ router + ORCHESTRATOR │ │ + fs + git │ └────────▲────────┘ └──────▲─────────────┘ │ hook payloads │ stdin / stdout └─────────┬──────────────┘ @@ -36,7 +36,7 @@ Two data planes feed one renderer: - **Hive / event plane.** `hive.ts` is the on-disk multi-agent layer; `hooks.ts` runs the hook server that provider bridges POST lifecycle payloads to (`cth-hook` for Claude Code, `agy-hook` for Antigravity). `memory.ts` wraps the semantic memory CLI. The router delivers messages, drains - provider outboxes, the GOD agent adjudicates, and idle/inbox wakeups keep workers draining mail. + provider outboxes, the ORCHESTRATOR agent adjudicates, and idle/inbox wakeups keep workers draining mail. ## Project structure @@ -63,7 +63,7 @@ src/ App.tsx top-level layout + wiring design/ tokens.css / tokens.ts / global.css (design source of truth) components/ PixelPanel, AgentDetailPanel, CommandBar, ApprovalsPanel, MemoryPanel, … - CommandCenterPanel, Michael's control surface (Terminal/Floor/Memory/Activity/Tasks/Triggers/Handbook tabs) + CommandCenterPanel, the orchestrator's control surface (Terminal/Floor/Memory/Activity/Tasks/Triggers/Handbook tabs) ToolWaterfall, per-agent tool-span waterfall for the observability view TasksKanban, dependency-aware kanban board (Tasks tab) ThreadsPanel, hive message conversation viewer (Messages tab) @@ -84,6 +84,6 @@ docs/message-queue.md who may type into an agent's terminal, and when The aesthetic is **Animal Crossing × Earthbound × SNES menu UI** — pixel-snapped, chunky, friendly. [`DESIGN.md`](../DESIGN.md) is canonical; every component derives from its tokens. The Munder Difflin -brand layers a **Dunder-Mifflin maroon** (`#6E1423`) and **gold** (`#F4D35E`) on top for logo and -chrome. The 15 avatars are the cast of *The Office*, differentiated by hair/skin/shirt recipes. +brand layers a **maroon** (`#6E1423`) and **gold** (`#F4D35E`) on top for logo and +chrome. The 15 avatars are a generic cast, differentiated by hair/skin/shirt recipes. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 639970f30f..79c991ccbe 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -9,7 +9,7 @@ > pastel color-blocks accent the feature cards; one warm **yellow** drives every CTA. > > We keep Munder Difflin's own identity inside this system: the **maroon brand mark**, the -> name, the *Office* parody, the **GOD / hive / MemPalace** story, and the real captured +> name, the *Office* parody, the **ORCHESTRATOR / hive / MemPalace** story, and the real captured > product footage. "Munder Difflin is a paper company," so the cream-paper canvas is on-theme. > > **History:** this replaces the previous *dark, flat, rounded* system. That direction @@ -30,7 +30,7 @@ 5. **Product-led, static.** The hero *shows the product* through a real captured still (`media/og.png`), seated inside a flat, hairline-bordered window. The "How" section uses hand-built **static** HTML/CSS/SVG illustrations (the floor graph, MemPalace radial recall, - GOD + hive graph) drawn in the warm-paper / neo-brutalist theme — ink-bordered node chips, + ORCHESTRATOR + hive graph) drawn in the warm-paper / neo-brutalist theme — ink-bordered node chips, pastel blocks, mono labels. No video, no looping illustration motion — every illustration reads as a finished, frozen still. 6. **Restraint in motion.** Reveal-on-scroll + a neo-brutalist press on hover (translate + @@ -80,7 +80,7 @@ small playful pastel set for feature blocks. | `--ink` | All text on light, **and all borders**, and dark-band/title-bar fills. | | `--yellow` | Primary CTA fill only. Never a large flat background. | | `--sky` | Exactly one highlighted phrase per headline; alt CTA on the dark band. **Large text only** (low contrast at body size). | -| `--maroon` | Brand mark, link hover, one emphasized noun (e.g. "GOD orchestrator"). | +| `--maroon` | Brand mark, link hover, one emphasized noun (e.g. "ORCHESTRATOR orchestrator"). | | pastels | Feature-card / problem-card / how-panel blocks — one tint each, rotated. | ### Ambient texture @@ -118,7 +118,7 @@ Inter is fallback only. | Wordmark | mono | 13px | 700 | 0.02em | "MUNDER DIFFLIN" | **Emphasis:** in the big "What" statement, key nouns use `--ink` (bold), `--sky` (hive mind, -long-term memory) and `--maroon` (GOD orchestrator). +long-term memory) and `--maroon` (ORCHESTRATOR orchestrator). --- @@ -185,27 +185,27 @@ The **hero** uses a captured product **still** (`media/og.png`) inside a flat `. video element, nothing to autoplay. The **three "How" feature cards likewise use no video** — each holds a hand-built static HTML/CSS/SVG illustration (`.ill`, `16/11`) inside a flat `.win`, drawn in the warm-paper / neo-brutalist theme so it reads as finished product art. The whole page -is now still: the envelope and GOD packets are placed at fixed `offset-distance` points, not animated. +is now still: the envelope and ORCHESTRATOR packets are placed at fixed `offset-distance` points, not animated. | Slot | Illustration | Panel tint | Accent | |---|---|---|---| | Hero — the floor | static product still `media/og.png` | — | — | | 01 The Simulation | desk-grid floor: ink-bordered agent node chips (`.nodet`), SVG `.edge` links, a static `✉️` placed mid-route desk→desk | `--sky-soft` | `--sky` status dots | | 02 The Memory — MemPalace | radial recall: central maroon `.mem-core`, six `.mem-chip` memories on SVG `.mem-edge` spokes, matched ones highlighted (`.hit`, `--lilac` + maroon edge) | `--lilac` | `--maroon` | -| 03 The Orchestration — GOD + hive | node graph: a `GOD · you` node routing to research/build/review along edges with static maroon `.flow-dot` packets frozen mid-route, plus an `.ap` approvals card (`approve`/`hold`) | `--peach` | `--yellow` GOD, `--mint` approve | +| 03 The Orchestration — ORCHESTRATOR + hive | node graph: a `ORCHESTRATOR · you` node routing to research/build/review along edges with static maroon `.flow-dot` packets frozen mid-route, plus an `.ap` approvals card (`approve`/`hold`) | `--peach` | `--yellow` ORCHESTRATOR, `--mint` approve | **One shared primitive set across all three illustrations** (this is what makes them read as a family): a faint dotted-paper grid behind every `.ill`; a single white, ink-bordered, offset- shadowed node chip `.node` (with a `.av` color swatch + optional status `.dot`); a larger `.node.hub` -variant for the center (GOD / MemPalace — identical form, themed swatch); SVG `.edge` connectors +variant for the center (ORCHESTRATOR / MemPalace — identical form, themed swatch); SVG `.edge` connectors (`.edge.on` = maroon "active flow"); plus `.ap` (approvals card) and `.lbl` (caption tag) drawn in -the same ink-bordered, offset-shadowed idiom. Nothing animates — the envelope and GOD packets sit +the same ink-bordered, offset-shadowed idiom. Nothing animates — the envelope and ORCHESTRATOR packets sit at fixed `offset-distance` points. No external images, no video weight — pure markup. The three illustrations stay **distinct in layout** so 01 and 03 don't collide: 01 is a flat **peer floor** (scattered desks, criss-cross messages, an envelope in transit, status dots), 02 is a **radial recall** (central MemPalace hub, six memory chips on spokes, recalled ones highlighted), -03 is a **top-down hub** (GOD routing down to research/build/review with packets + an approvals card). +03 is a **top-down hub** (ORCHESTRATOR routing down to research/build/review with packets + an approvals card). The retired `media/how-*.{webm,mp4}` footage stays on disk but is no longer referenced. @@ -273,7 +273,7 @@ CNAME → `munderdiffl.in`. ## 12. Lineage note The visual language is intentionally close to **cubicle.run** (a product in the same space). -Differentiation is carried by: the **maroon** brand tone + `MD` mark, the **GOD / hive / +Differentiation is carried by: the **maroon** brand tone + `MD` mark, the **ORCHESTRATOR / hive / MemPalace** narrative, MD's own copy, the real pixel-office hero footage, and the hand-built "How" illustrations. Do not copy cubicle's section wording or exact palette values verbatim. diff --git a/docs/media/README.md b/docs/media/README.md index 112ab742dc..1f9e2d103b 100644 --- a/docs/media/README.md +++ b/docs/media/README.md @@ -10,7 +10,7 @@ Media assets the landing page (`../index.html`) embeds. - `hero-poster.svg` — office floor + live terminal (legacy placeholder) - `how-agents-poster.svg` - `how-mempalace-poster.svg` -- `how-god-hive-poster.svg` +- `how-orchestrator-hive-poster.svg` ## What to add @@ -22,7 +22,7 @@ Rendered video loops (committed, since GitHub Pages serves them statically). Pro | `hero.webm` / `hero.mp4` | **Your real screen recording** of a busy agent session (preferred), or `npm run render:hero:*` in `../../landing-remotion` | | `how-agents.webm` / `.mp4` | `../../landing-remotion` → `npm run render:agents:*` | | `how-mempalace.webm` / `.mp4` | `npm run render:mempalace:*` | -| `how-god-hive.webm` / `.mp4` | `npm run render:godhive:*` | +| `how-orchestrator-hive.webm` / `.mp4` | `npm run render:orchestratorhive:*` | See `../../landing-remotion/README.md` for the full render workflow. The page degrades gracefully: if a video file is missing, the matching poster stays on screen. diff --git a/docs/media/how-god-hive.webm b/docs/media/how-orchestrator-hive.webm similarity index 100% rename from docs/media/how-god-hive.webm rename to docs/media/how-orchestrator-hive.webm diff --git a/docs/message-queue.md b/docs/message-queue.md index 3a844bdfc1..cfad10cf18 100644 --- a/docs/message-queue.md +++ b/docs/message-queue.md @@ -23,7 +23,7 @@ One place types automatic messages into a live agent's PTY: the **drain loop**, `useHive.ts` effect #4. Everything that wants to reach a running agent enqueues into the MD queue and lets the drain decide when. -(The single exception is the god agent's boot sequence, `useHive.ts:284`, which +(The single exception is the orchestrator's boot sequence, `useHive.ts:284`, which writes its remote-control command and orientation prompt directly. That PTY was spawned milliseconds earlier and is covered by the boot-grace window, so there is no user draft it could land on.) diff --git a/docs/model-catalog.json b/docs/model-catalog.json index 8f695d88ed..2c2d819272 100644 --- a/docs/model-catalog.json +++ b/docs/model-catalog.json @@ -42,13 +42,16 @@ "gemini": [ { "label": "CLI default", "minAppVersion": null, "maxAppVersion": null }, { "id": "auto", "label": "Auto", "minAppVersion": null, "maxAppVersion": null }, + { "id": "gemini-4-argon", "label": "Gemini 4 Argon", "minAppVersion": null, "maxAppVersion": null }, { "id": "pro", "label": "Pro", "minAppVersion": null, "maxAppVersion": null }, { "id": "flash", "label": "Flash", "minAppVersion": null, "maxAppVersion": null }, { "id": "flash-lite", "label": "Flash Lite", "minAppVersion": null, "maxAppVersion": null } ], "antigravity": [ { "label": "CLI default", "minAppVersion": null, "maxAppVersion": null }, - { "id": "Gemini 4 Argon (High)", "label": "Gemini 4 Argon · High (ultra plan)", "minAppVersion": null, "maxAppVersion": null }, + { "id": "Gemini 4 Argon (High)", "label": "Gemini 4 Argon · High", "minAppVersion": null, "maxAppVersion": null }, + { "id": "Gemini 4 Argon (Medium)", "label": "Gemini 4 Argon · Med", "minAppVersion": null, "maxAppVersion": null }, + { "id": "Gemini 4 Argon (Low)", "label": "Gemini 4 Argon · Low", "minAppVersion": null, "maxAppVersion": null }, { "id": "Gemini 3.1 Pro (High)", "label": "Gemini 3.1 Pro · High", "minAppVersion": null, "maxAppVersion": null }, { "id": "Gemini 3.1 Pro (Low)", "label": "Gemini 3.1 Pro · Low", "minAppVersion": null, "maxAppVersion": null }, { "id": "Gemini 3.8 Flash (High)", "label": "Gemini 3.8 Flash · High", "minAppVersion": null, "maxAppVersion": null }, @@ -113,7 +116,6 @@ { "id": "gpt-5.6-luna-high", "label": "GPT-5.6 Luna 1M High (cheap)", "minAppVersion": null, "maxAppVersion": null }, { "id": "gpt-5.6-sol-medium", "label": "GPT-5.6 Sol 1M", "minAppVersion": null, "maxAppVersion": null }, { "id": "gpt-5.6-sol-high", "label": "GPT-5.6 Sol 1M High", "minAppVersion": null, "maxAppVersion": null }, - { "id": "gemini-4-argon-high", "label": "Gemini 4 Argon High", "minAppVersion": null, "maxAppVersion": null }, { "id": "gemini-3.8-flash-high", "label": "Gemini 3.8 Flash", "minAppVersion": null, "maxAppVersion": null }, { "id": "gemini-3.7-flash-high", "label": "Gemini 3.7 Flash", "minAppVersion": null, "maxAppVersion": null }, { "id": "claude-fable-5-1-thinking-high", "label": "Fable 5.1 1M Thinking (no ZDR)", "minAppVersion": null, "maxAppVersion": null }, diff --git a/docs/theming/THEMING.md b/docs/theming/THEMING.md new file mode 100644 index 0000000000..3e00449110 --- /dev/null +++ b/docs/theming/THEMING.md @@ -0,0 +1,145 @@ +# Making and installing a theme + +A theme pack re-skins the pixel office: the floor plan, tiles, seats, colours, and optionally +fonts, UI tokens and a cast of characters. A pack is a plain folder. Nothing is compiled. + +The only pack bundled with the app is **plain** (`resources/themes/plain`). It is generated from +flat shapes (`scripts/theme-packs/plain/gen_pack.py`) so it carries no third-party art. + +## Quick start + +```bash +npm run theme:new -- my-office # copies `plain` to ./my-themes/my-office +npm run theme:validate -- my-themes/my-office +MD_THEMES_DIR=./my-themes npm run dev # or set "Extra themes folder" in Settings +``` + +Then open **Settings → Office theme**, press **Rescan**, and pick your pack. A pack with errors +shows greyed out with the exact problems listed under the grid. + +## Where packs are found + +Each pack is a folder whose name is its id (`^[a-z0-9][a-z0-9-]{1,39}$`; `office`, `default` and +`none` are reserved). The app scans, in order, and the first folder with a given id wins: + +1. `MD_THEMES_DIR` (a `;`-separated list on Windows, `:` elsewhere) +2. `/themes` (the **Open themes folder** button creates and opens it) +3. The **Extra themes folder** from Settings (`themesDir` in config) +4. The bundled `themes` folder + +Changes are picked up when you press **Rescan**. Switching the active theme starts a fresh cast +(the orchestrator carries over, other agents are archived). + +## Folder layout + +``` +my-office/ + theme.json manifest (required) + map.tmj Tiled map, JSON format (required) + tilesets/*.png 16x16 tile sheets referenced by the manifest (required) + chrome/tokens.css optional UI colour tokens + chrome/fonts/*.woff2 optional fonts (<= 4, each <= 300 KB) + cast/cast.json optional character roster + cast/sprites/.png optional sprite per roster member (108x32) +``` + +## theme.json + +```jsonc +{ + "schema": 1, + "id": "my-office", // must equal the folder name + "name": "My Office", + "version": "1.0.0", + "author": "you", + "license": "MIT", // required if you ship tilesets; credit art creators here + "blurb": "One line for the picker", + "swatch": "#2c2e3a", // picker colour chip + "map": "map.tmj", + "tilesets": [ + { "file": "tilesets/main.png", "embedded": true } // see "Tilesets" + ], + "seats": { + "primary": ["desk-ceo", "pc-1", "pc-2"], // [0] MUST be desk-ceo + "cafe": ["cafe-seat-1"], + "cafeStands": [["cafe-stand-coffee", "coffee"]], // optional stands + "facing": { "desk-ceo": "up" } // up|down|left|right, optional + }, + "coffee": { "trayTile": {"x":1,"y":1}, "trayStand": {"x":1,"y":2}, "machineStand": {"x":2,"y":2}, + "sinkTile": {"x":3,"y":1}, "sinkStand": {"x":3,"y":2}, "maxCups": 4 }, // optional + "anchors": { "calendar": {"x":4,"y":1}, "boards": {"x":8,"y":1}, "clock": {"x":12,"y":1} }, + "errandSpots": [], // optional little trips: water, window, dispenser, fridge, shelf, bin, smoke + "monitor": { "offTopLeftGid": 6, "onGids": [[7, 0, 0]] }, // the screen tile and its "on" swap + "palette": { "background": "#14151c", + "noteColors": { "todo": "#f2df8a", "doing": "#9ecbf0", "blocked": "#f0a3a3", "done": "#a8e0b0" } }, + "copy": { "orchestratorName": "Captain" }, // optional, see below + "fonts": [], "chrome": { "tokens": "chrome/tokens.css", "defaultMode": "dark" } +} +``` + +Required: `schema`, `id`, `name`, `version`, `author`, `license`, `map`, `tilesets`, `seats`, +`anchors`, `monitor`, `palette`. Everything else is optional. Bad optional fields (fonts, +tokens, copy, cast) are reported as warnings and dropped, never fatal. + +### Naming the orchestrator + +The code always calls the lead agent *orchestrator*. What people **see** is a display name, +resolved in this order: + +1. the name the user typed when renaming it in the app +2. `copy.orchestratorName` from the active theme (1-40 characters) +3. `Orchestrator` + +So a themed pack can present the lead as "Captain" or "Michael" without anything in the code +or the agents' protocol changing, and a user's own rename always wins. + +## map.tmj (Tiled) + +Author in [Tiled](https://www.mapeditor.org/) and export as JSON (`.tmj`). Rules: + +- Orthogonal, **finite**, **16x16 px tiles**, between 8 and 128 tiles per side. Tile layer data + must be a plain array (set Tile Layer Format to *CSV*, not base64/zlib). +- Tile layers, by exact name: `floor`, `walls`, `furniture-below`, `furniture-above`, `collision`. + Any non-zero tile in `collision` blocks walking. +- Object layers: `spawn-points` (points; the **name** is what matters) and `zones` (optional; + `boardroom` is recognised). +- Every seat named in `theme.json` needs a point with the same name in `spawn-points`. `desk-ceo` + is the orchestrator's seat. Seats starting with `desk-`, `pc-`, `warroom-` and the `entrance` + point are always walkable. Add an `entrance` point: agents wait there for a free desk. +- The monitor overlay is the tile `monitor.offTopLeftGid` on `furniture-above`, two rows above the + seat; `onGids` is `[gid, dx, dy]` tiles drawn when the agent is working. + +## Tilesets + +PNG, a multiple of 16 px, at most 2048x8192 and 4 MB each, at most 8 per pack. Either: + +- **embedded**: the map has the tileset inline with `firstgid`/`tilecount`; the manifest entry is + `{ "file": "...", "embedded": true }`, or +- **external**: the manifest gives `firstgid` (defaults to following the previous tileset) and the + app derives size and columns from the PNG header. + +More than 5% of tile ids not covered by any tileset rejects the pack. + +## Cast (optional) + +`cast/cast.json` is an array of `{ "name", "displayName", "shirt", "blurb" }`. `name` is letters, +digits, `-` or `_`. Add `cast/sprites/.png`, 108x32: six 18x32 frames (front stand, step L, +step R, then the same three from behind). A missing sprite falls back to a drawn character and +produces a warning, not an error. Without `cast.json` the built-in roster is used. + +## Limits + +Pack total 24 MB (warning at 12 MB), manifest 256 KB, map 2 MB, `tokens.css` 16 KB (no `url()` or +`@import`), fonts `.woff2` only. The authoritative list is `PACK_LIMITS` in +`src/shared/themePack.ts`; `validatePack` there is the contract. + +## Checking your work + +`npm run theme:validate -- [ ...]` runs the same validator the app uses and prints +every `error` / `warn` with a code and a path. Exit code 1 means the app would refuse the pack. + +## Licensing + +Only commit art you have the right to. Third-party packs (for example ones made from commercial +tilesets or a TV show's likeness) belong in your own themes folder, not in a public repo. This +repository gitignores everything in `resources/themes` except `plain`. diff --git a/docs/usage-endpoint.md b/docs/usage-endpoint.md new file mode 100644 index 0000000000..82c717a254 --- /dev/null +++ b/docs/usage-endpoint.md @@ -0,0 +1,115 @@ +# Usage endpoint + +Agent engines meter you: a five-hour window, a weekly window, sometimes more. If you already +track how much of each is left, you can point the app at that data and the orchestrator will use +it when it routes work. + +Each **standup**, the app makes one `GET` to your URL, writes the result to +`usage-availability.json` in the hive root, and appends a short summary to the standup message. +The orchestrator then knows things like: + +- *"codex has 8% of its five-hour window left: stop sending it work."* +- *"claude's five-hour window resets in 1h 10m with 60% unused: good time to give it more."* + +It is optional and off by default. If the endpoint is down, the standup still goes out with a +line saying the usage data was unavailable. + +## Turn it on + +Settings → Autonomy & Budgets → **Usage endpoint**. Enter the URL, an optional bearer token and a +timeout, then press **Test** to see exactly what the orchestrator would be told. Nothing is saved +until you save settings. + +## What your endpoint returns + +`GET ` → `200` with JSON: + +```json +{ + "timestamp": "2026-10-17T14:40:00Z", + "agents": { + "claude": { + "five_hour": { "remaining_pct": 90.0, "resets_at": "2026-10-17T18:00:00Z" }, + "weekly": { "remaining_pct": 69.5, "resets_at": "2026-10-21T00:00:00Z" }, + "availability": "available" + }, + "codex": { + "five_hour": { "remaining_pct": 8.0, "resets_at": "2026-10-17T15:30:00Z" }, + "availability": "available" + } + } +} +``` + +| Field | Meaning | +|---|---| +| `agents.` | Any engine name you like. Use the names you use for your agents (`claude`, `codex`, `gemini`, ...). Up to 64. | +| `` | Any key whose value is an object with a numeric `remaining_pct`. `five_hour` and `weekly` are conventions, not requirements; you can report `daily`, `monthly`, etc. Up to 8 per engine. | +| `remaining_pct` | 0–100. Values outside the range are clamped. | +| `resets_at` | ISO-8601 timestamp. Optional; without it the orchestrator gets the percentage only. | +| `availability` | Free text. Anything other than `available` is treated as "do not send new work here". | +| `timestamp` | When your data was measured. Optional. | + +Everything is optional except having at least one engine with a window or an `availability`. +Unknown keys, malformed windows and bad dates are dropped, not rejected, so you can add your own +fields. + +The response must be under 256 KB and the request is answered within your timeout (default 5 s, +max 30 s). Redirects are refused, so a token is never forwarded to a host you did not enter. + +## How the orchestrator reads it + +| Situation | Hint added to the standup | +|---|---| +| Any window at or below 15%, or `availability` is not `available` | *LOW: avoid new work here, reroute to another engine* | +| A window resets within 2 hours and still has 30% or more left | *use it or lose it ... good time to hand this engine more work* | +| Anything else | The numbers only | + +The thresholds are constants in `src/shared/usageEndpoint.ts` (`LOW_PCT`, `BURN_WITHIN_MS`, +`BURN_MIN_PCT`). + +## A reference server (Flask) + +Replace `read_usage()` with however you gather the numbers (scraping CLI output, a database, a +cron job that writes a file). + +```python +# usage_server.py — pip install flask +import os +from flask import Flask, jsonify, request, abort + +app = Flask(__name__) +TOKEN = os.environ.get("USAGE_TOKEN") # optional; set it and put the same value in Settings + + +def read_usage(): + return { + "claude": { + "five_hour": {"remaining_pct": 90.0, "resets_at": "2026-10-17T18:00:00Z"}, + "weekly": {"remaining_pct": 69.5, "resets_at": "2026-10-21T00:00:00Z"}, + "availability": "available", + } + } + + +@app.get("/api/agents/remaining") +def remaining(): + if TOKEN and request.headers.get("Authorization") != f"Bearer {TOKEN}": + abort(401) + from datetime import datetime, timezone + return jsonify({"timestamp": datetime.now(timezone.utc).isoformat(), "agents": read_usage()}) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8080) +``` + +Quick check: `curl -H "Authorization: Bearer $USAGE_TOKEN" http://localhost:8080/api/agents/remaining`. + +## Security + +- The URL may be plain `http://` so a box on your LAN works. Keep it on your LAN or a VPN, or put + it behind TLS if it is reachable from anywhere else. +- The bearer token is stored in the app's `config.json` in plain text. + Use a token that only grants read access to this one endpoint. +- The app only ever reads from the endpoint. It never sends your agents' prompts, code or tokens. diff --git a/electron-builder.yml b/electron-builder.yml index 20eacff7c8..d7ee43f2fb 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -1,5 +1,5 @@ -appId: in.munderdiffl.app -productName: Munder Difflin +appId: com.agentstage.app +productName: Agent Stage copyright: Copyright © 2026 Chaitanya Giri # Where electron-updater looks for new versions (latest*.yml + assets on GitHub @@ -8,8 +8,8 @@ copyright: Copyright © 2026 Chaitanya Giri # knows which repo to poll. publish: provider: github - owner: chaitanyagiri - repo: munder-difflin + owner: jdell64 + repo: agent-stage # Bake the update toast's "What's new" list straight into latest*.yml. # @@ -38,7 +38,7 @@ afterSign: build/notarize.cjs # Register the munderdifflin:// URL scheme (shareable-hires deep link) in the # packaged app: CFBundleURLTypes on macOS, registry entries via NSIS on Windows. protocols: - - name: Munder Difflin + - name: Agent Stage schemes: - munderdifflin @@ -60,6 +60,8 @@ files: # resources dir (process.resourcesPath). It runs under `node`, NOT inside the # asar, so the office worker can invoke `node /md-slack-reply.cjs`. extraResources: + - from: build/agent-stage.png + to: agent-stage.png - from: resources/md-slack-reply.cjs to: md-slack-reply.cjs # Knowledge Graph agent CLI + its pure-JS core. Agents invoke `node @@ -75,6 +77,9 @@ extraResources: # repo resources/skills). Directory copy preserves the /SKILL.md tree. - from: resources/skills to: skills + # Built-in theme packs (§2.6 discovery order) + - from: resources/themes + to: themes asarUnpack: - "**/node_modules/node-pty/**" @@ -87,20 +92,20 @@ npmRebuild: true # ---- macOS ---- mac: category: public.app-category.developer-tools - icon: build/icon.icns + icon: build/agent-stage.icns # Declare WHY the app touches the user's folders. macOS shows these strings in # the privacy prompt and, for a signed app, remembers the grant — so the user # is asked ONCE instead of on every agent action. extendInfo: - NSDocumentsFolderUsageDescription: "Munder Difflin runs your Claude Code agents, which read and write files in the project folders you point them at." - NSDesktopFolderUsageDescription: "Munder Difflin's agents may work on projects located on your Desktop." - NSDownloadsFolderUsageDescription: "Munder Difflin's agents may work on projects located in your Downloads." - NSRemovableVolumesUsageDescription: "Munder Difflin's agents may work on projects located on external volumes." - NSNetworkVolumesUsageDescription: "Munder Difflin's agents may work on projects located on network volumes." + NSDocumentsFolderUsageDescription: "Agent Stage runs your Claude Code agents, which read and write files in the project folders you point them at." + NSDesktopFolderUsageDescription: "Agent Stage's agents may work on projects located on your Desktop." + NSDownloadsFolderUsageDescription: "Agent Stage's agents may work on projects located in your Downloads." + NSRemovableVolumesUsageDescription: "Agent Stage's agents may work on projects located on external volumes." + NSNetworkVolumesUsageDescription: "Agent Stage's agents may work on projects located on network volumes." # The microphone is used by two opt-in, off-by-default voice features: Free # Flow dictation (Groq transcription) and Realtime Michael (live voice chat # with the orchestrator via OpenAI). Mic access only happens while one is on. - NSMicrophoneUsageDescription: "Munder Difflin uses your microphone only for its opt-in voice features: Free Flow dictation (transcribed by Groq into the message queue) and talking to Michael in real time (streamed to OpenAI). The mic is off unless you turn one of these on." + NSMicrophoneUsageDescription: "Agent Stage uses your microphone only for its opt-in voice features: Free Flow dictation (transcribed by Groq into the message queue) and talking to Michael in real time (streamed to OpenAI). The mic is off unless you turn one of these on." # Hardened runtime + entitlements so the spawned `claude` agents inherit this # app's TCC responsibility (one grant covers them all) and node-pty can load. hardenedRuntime: true @@ -133,11 +138,11 @@ mac: artifactName: Munder-Difflin-${version}-mac-${arch}.${ext} dmg: - title: Munder Difflin ${version} + title: Agent Stage ${version} # ---- Windows ---- win: - icon: build/icon.ico + icon: build/agent-stage.ico target: - target: nsis arch: [x64] @@ -149,7 +154,7 @@ nsis: perMachine: false allowToChangeInstallationDirectory: true artifactName: Munder-Difflin-${version}-win-x64-setup.exe - shortcutName: Munder Difflin + shortcutName: Agent Stage portable: artifactName: Munder-Difflin-${version}-win-x64-portable.exe @@ -157,7 +162,7 @@ portable: # ---- Linux ---- linux: category: Development - icon: build/icon.png + icon: build/agent-stage.png target: - target: AppImage arch: [x64] diff --git a/hive/docs/integration-templates.md b/hive/docs/integration-templates.md index f406782476..435bccbecd 100644 --- a/hive/docs/integration-templates.md +++ b/hive/docs/integration-templates.md @@ -45,7 +45,7 @@ A template is a preset that seeds an `IntegrationRecord`. Its fields (per spec **Basic auth (Jira/Confluence)** is carried as a pre-encoded `header` value: the user pastes `Basic ` and the broker injects it verbatim under `Authorization`. A dedicated `basic` authType (paste email + token separately, broker -does the base64) would be cleaner — flagged to god as a proposed enum addition. +does the base64) would be cleaner — flagged to orchestrator as a proposed enum addition. **OAuth tools (Gmail / Google Calendar / Salesforce)** are **not registered in v1**: Jim's `IntegrationAuthType` has no `oauth2`, and OAuth refresh is a v1 non-goal (spec @@ -156,7 +156,7 @@ needs a per-org instance host (`.my.salesforce.com`) the user supplies `src/shared/integrationTemplates.ts` (and its interface) was removed — **one type, one enum, one registry, no competing catalog**. Verified `typecheck:node` + `typecheck:web` exit 0. -- **Flagged gaps (god → Jim, enum widening rather than forking the type):** +- **Flagged gaps (orchestrator → Jim, enum widening rather than forking the type):** 1. **OAuth (Gmail/Calendar/Salesforce):** no `oauth2` authType; v1 non-goal. The 3 are documented (above) but unregistered. → add a broker-OAuth auth type, or defer. 2. **Basic auth (Jira/Confluence):** works today as `header` + a pre-encoded @@ -168,7 +168,7 @@ needs a per-org instance host (`.my.salesforce.com`) the user supplies - **Three `IntegrationTemplate` shapes still exist** across the tree (this file → conformed; Jim's `integrations.ts`; Ryan's renderer `registryClient.ts` mock). Full collapse to one needs Ryan's Settings UI to consume the shared templates — a - cross-agent step for god to sequence. + cross-agent step for orchestrator to sequence. - **Verify-on-wire before GA:** Jira enhanced JQL is migrating to `/rest/api/3/search/jql`; Notion `Notion-Version` advances periodically; pinned API versions (`v60.0`, `2022-06-28`, `v2`) re-check at integration time. diff --git a/landing-remotion/README.md b/landing-remotion/README.md index 8fbf169054..42d14e91fa 100644 --- a/landing-remotion/README.md +++ b/landing-remotion/README.md @@ -11,7 +11,7 @@ complete before you render anything. |---|---|---| | `HowAgents` | `docs/media/how-agents.{webm,mp4}` | Multiple terminal agents managed & visualized in the office simulation | | `HowMemPalace` | `docs/media/how-mempalace.{webm,mp4}` | The shared MemPalace memory layer + millisecond recall | -| `HowGodHive` | `docs/media/how-god-hive.{webm,mp4}` | The GOD agent orchestrating every agent through the hive | +| `HowOrchestratorHive` | `docs/media/how-orchestrator-hive.{webm,mp4}` | The orchestrator orchestrating every agent through the hive | | `HeroFallback` | `docs/media/hero.{webm,mp4}` | *Optional* synthetic hero — use only if you have no real screen recording yet | ## Preview live @@ -32,7 +32,7 @@ npm run render:all # all three HOW clips (webm + mp4) # or one at a time npm run render:agents:webm -npm run render:godhive:mp4 +npm run render:orchestratorhive:mp4 # optional synthetic hero: npm run render:hero:webm && npm run render:hero:mp4 ``` diff --git a/landing-remotion/package.json b/landing-remotion/package.json index baf6016b95..c781182225 100644 --- a/landing-remotion/package.json +++ b/landing-remotion/package.json @@ -10,11 +10,11 @@ "render:agents:mp4": "remotion render src/index.ts HowAgents ../docs/media/how-agents.mp4 --codec=h264", "render:mempalace:webm": "remotion render src/index.ts HowMemPalace ../docs/media/how-mempalace.webm --codec=vp8", "render:mempalace:mp4": "remotion render src/index.ts HowMemPalace ../docs/media/how-mempalace.mp4 --codec=h264", - "render:godhive:webm": "remotion render src/index.ts HowGodHive ../docs/media/how-god-hive.webm --codec=vp8", - "render:godhive:mp4": "remotion render src/index.ts HowGodHive ../docs/media/how-god-hive.mp4 --codec=h264", + "render:orchestratorhive:webm": "remotion render src/index.ts HowOrchestratorHive ../docs/media/how-orchestrator-hive.webm --codec=vp8", + "render:orchestratorhive:mp4": "remotion render src/index.ts HowOrchestratorHive ../docs/media/how-orchestrator-hive.mp4 --codec=h264", "render:hero:webm": "remotion render src/index.ts HeroFallback ../docs/media/hero.webm --codec=vp8", "render:hero:mp4": "remotion render src/index.ts HeroFallback ../docs/media/hero.mp4 --codec=h264", - "render:all": "npm run render:agents:webm && npm run render:agents:mp4 && npm run render:mempalace:webm && npm run render:mempalace:mp4 && npm run render:godhive:webm && npm run render:godhive:mp4" + "render:all": "npm run render:agents:webm && npm run render:agents:mp4 && npm run render:mempalace:webm && npm run render:mempalace:mp4 && npm run render:orchestratorhive:webm && npm run render:orchestratorhive:mp4" }, "dependencies": { "@remotion/cli": "4.0.286", diff --git a/landing-remotion/src/HeroFallback.tsx b/landing-remotion/src/HeroFallback.tsx index 8d3c02ceba..3ae4e1a785 100644 --- a/landing-remotion/src/HeroFallback.tsx +++ b/landing-remotion/src/HeroFallback.tsx @@ -3,7 +3,7 @@ import { AbsoluteFill, Sequence } from 'remotion'; import { C, FONT } from './theme'; import { press } from './fonts'; import { HowAgents } from './HowAgents'; -import { HowGodHive } from './HowGodHive'; +import { HowOrchestratorHive } from './HowOrchestratorHive'; import { HowMemPalace } from './HowMemPalace'; // Optional synthetic hero used ONLY if you don't have a real screen recording yet. @@ -11,7 +11,7 @@ import { HowMemPalace } from './HowMemPalace'; export const HeroFallback: React.FC = () => ( - +
{ - + diff --git a/landing-remotion/src/HowGodHive.tsx b/landing-remotion/src/HowOrchestratorHive.tsx similarity index 91% rename from landing-remotion/src/HowGodHive.tsx rename to landing-remotion/src/HowOrchestratorHive.tsx index 57a0ccb1fd..f4df7b1781 100644 --- a/landing-remotion/src/HowGodHive.tsx +++ b/landing-remotion/src/HowOrchestratorHive.tsx @@ -13,7 +13,7 @@ const Worker: React.FC<{ x: number; fill: string; name: string }> = ({ x, fill, ); -export const HowGodHive: React.FC = () => { +export const HowOrchestratorHive: React.FC = () => { return (
@@ -25,10 +25,10 @@ export const HowGodHive: React.FC = () => {
YOU
- {/* GOD */} + {/* ORCHESTRATOR */}
-
GOD
+
ORCHESTRATOR
orchestrator
route · assign · escalate
@@ -54,7 +54,7 @@ export const HowGodHive: React.FC = () => { - {/* flows: you→god, god→hive, hive→workers, escalation→approvals */} + {/* flows: you→orchestrator, orchestrator→hive, hive→workers, escalation→approvals */} @@ -62,7 +62,7 @@ export const HowGodHive: React.FC = () => { - +
MUNDER DIFFLIN diff --git a/landing-remotion/src/Root.tsx b/landing-remotion/src/Root.tsx index ab9619b2d8..19e11594ce 100644 --- a/landing-remotion/src/Root.tsx +++ b/landing-remotion/src/Root.tsx @@ -3,7 +3,7 @@ import { Composition } from 'remotion'; import { FPS } from './theme'; import { HowAgents } from './HowAgents'; import { HowMemPalace } from './HowMemPalace'; -import { HowGodHive } from './HowGodHive'; +import { HowOrchestratorHive } from './HowOrchestratorHive'; import { HeroFallback } from './HeroFallback'; // 16:9 clips sized to match the landing page's video frames. @@ -11,7 +11,7 @@ export const RemotionRoot: React.FC = () => ( <> - + ); diff --git a/package-lock.json b/package-lock.json index af7455c814..7678643990 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "munder-difflin", - "version": "0.4.6", + "name": "agent-stage", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "munder-difflin", - "version": "0.4.6", + "name": "agent-stage", + "version": "0.5.0", "hasInstallScript": true, "dependencies": { "@codemirror/lang-css": "^6.3.1", @@ -57,7 +57,8 @@ "electron-builder": "^25.1.8", "electron-vite": "^2.3.0", "typescript": "^5.6.3", - "vite": "^5.4.8" + "vite": "^5.4.8", + "vitest": "^2.1.9" } }, "node_modules/@babel/code-frame": { @@ -634,9 +635,9 @@ "license": "MIT" }, "node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -973,9 +974,9 @@ "license": "MIT" }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -1851,13 +1852,13 @@ "license": "MIT" }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", "license": "MIT", "optional": true, "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -2947,6 +2948,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webgpu/types": { "version": "0.1.70", "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.70.tgz", @@ -3339,9 +3453,9 @@ "peer": true }, "node_modules/archiver-utils/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "peer": true, @@ -3456,6 +3570,16 @@ "node": ">=0.8" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -3747,9 +3871,9 @@ "optional": true }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4150,6 +4274,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4206,6 +4347,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chownr": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", @@ -4516,9 +4667,9 @@ "license": "MIT" }, "node_modules/config-file-ts/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -4772,6 +4923,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-equal": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", @@ -4987,9 +5148,9 @@ "license": "MIT" }, "node_modules/dir-compare/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -5638,6 +5799,13 @@ "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "license": "MIT" }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -5752,6 +5920,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -5816,6 +5994,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -6009,9 +6197,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -6059,9 +6247,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -6406,9 +6594,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -6636,9 +6824,9 @@ } }, "node_modules/hono": { - "version": "4.13.3", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.3.tgz", - "integrity": "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==", + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", "license": "MIT", "optional": true, "engines": { @@ -6698,9 +6886,9 @@ } }, "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", "dev": true, "license": "BSD-2-Clause" }, @@ -6944,9 +7132,9 @@ } }, "node_modules/ip-address": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", - "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", "devOptional": true, "license": "MIT", "engines": { @@ -7808,6 +7996,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", @@ -9511,6 +9706,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pe-library": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", @@ -9798,9 +10010,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "optional": true, "dependencies": { @@ -9809,6 +10021,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/proxy-from-env": { @@ -10118,9 +10334,9 @@ "peer": true }, "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "peer": true, @@ -10334,9 +10550,9 @@ "license": "MIT" }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -10775,6 +10991,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -10938,9 +11161,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -11009,6 +11232,13 @@ "node": ">=8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -11035,6 +11265,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -11340,6 +11577,50 @@ "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -11858,6 +12139,95 @@ } } }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -11964,6 +12334,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -12174,9 +12561,9 @@ "peer": true }, "node_modules/zip-stream/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "peer": true, diff --git a/package.json b/package.json index eb5a2384df..19a6b4c129 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,25 @@ { - "name": "munder-difflin", - "version": "0.4.6", + "name": "agent-stage", + "version": "0.5.0", "private": true, - "description": "Clones for you and your team, working 24/7 — a free, local-first multi-agent harness that turns the CLI coding agent you already run into a clone of you, coordinating an office of agents on your machine.", + "description": "Agent Stage: an autonomous multi-agent office harness for Claude Code, Codex, and Antigravity.", "main": "out/main/index.js", "author": "Chaitanya Giri", - "homepage": "https://munderdiffl.in/", + "homepage": "https://github.com/jdell64/agent-stage", "repository": { "type": "git", - "url": "https://github.com/chaitanyagiri/munder-difflin.git" + "url": "https://github.com/jdell64/agent-stage.git" }, "scripts": { "dev": "electron-vite dev", + "dev:sandbox": "node -e \"const p=require('path');const os=require('os');const s=process.env.MD_SANDBOX_DIR||p.join(os.tmpdir(),'munder-difflin-sandbox');const u=p.join(s,'userData');const h=p.join(s,'harness');const t=p.join(s,'themes');const pipe='\\\\\\\\.\\\\pipe\\\\munder-difflin-dev-sandbox';const env=Object.assign({},process.env,{MD_USER_DATA_DIR:u,MD_THEMES_DIR:t,MD_HIVE_ROOT:h,MD_HOOKS_PIPE:pipe});require('child_process').spawn('npx',['electron-vite','dev'],{stdio:'inherit',shell:true,env});\"", "build": "electron-vite build && npm run copy:main-assets", "copy:main-assets": "node tools/copy-main-assets.cjs", "preview": "electron-vite preview", "postinstall": "electron-rebuild -f && node tools/ensure-pty-perms.cjs && node tools/patch-node-pty-conpty.cjs", + "test:themes": "vitest run", + "theme:validate": "node scripts/theme-packs/validate.cjs", + "theme:new": "node scripts/theme-packs/new.cjs", "typecheck:node": "tsc --noEmit -p tsconfig.node.json", "typecheck:web": "tsc --noEmit -p tsconfig.web.json", "typecheck": "npm run typecheck:node && npm run typecheck:web", @@ -75,7 +79,8 @@ "electron-builder": "^25.1.8", "electron-vite": "^2.3.0", "typescript": "^5.6.3", - "vite": "^5.4.8" + "vite": "^5.4.8", + "vitest": "^2.1.9" }, "overrides": { "app-builder-lib": { diff --git a/resources/skills/capabilities/SKILL.md b/resources/skills/capabilities/SKILL.md index 3fc1c1ac9c..e6252c730b 100644 --- a/resources/skills/capabilities/SKILL.md +++ b/resources/skills/capabilities/SKILL.md @@ -29,9 +29,9 @@ The harness injects these env vars (use them; don't hard-code paths): - `HIVE_ROOT` — the shared hive (`PROTOCOL.md`, the kanban, other agents). At boot you also have `identity.md` (who you are) and `HIVE_ROOT/PROTOCOL.md` -(the full coordination protocol). To message god or another agent, write ONE +(the full coordination protocol). To message orchestrator or another agent, write ONE message JSON into `$AGENT_DIR/outbox/` (schema in PROTOCOL.md). When finished, -send god an `"act":"done"` outbox message with a substantive result summary. +send orchestrator an `"act":"done"` outbox message with a substantive result summary. ## 2. Temporal skills — concrete date ranges, relative to now @@ -89,7 +89,7 @@ on one): reply (a short `*bold*` headline + the actual outcome/links), never a bare "done". - **Hive messaging.** Coordinate or hand off by writing an outbox message JSON - to god or another agent (`$AGENT_DIR/outbox/`). This is your always-available + to orchestrator or another agent (`$AGENT_DIR/outbox/`). This is your always-available channel. - **Semantic memory (MemPalace).** If enabled for you: `mempalace search ""` to recall shared knowledge, `mempalace wake-up` for a digest. @@ -109,7 +109,7 @@ then pass those concrete ISO bounds to the integration query. - Temporal skills are **read-only** helpers — they never write or reach the network. The broker mediates all external calls; you hold no credentials. -- Do **not** push or tag to any remote. Commit locally; **god** is the sole +- Do **not** push or tag to any remote. Commit locally; **orchestrator** is the sole integrator. Pause only for high-severity actions (remote push, paid/infra changes, deleting something you didn't create) — otherwise work autonomously. -- Finish by reporting to god (`"act":"done"`) with a real, substantive summary. +- Finish by reporting to orchestrator (`"act":"done"`) with a real, substantive summary. diff --git a/resources/themes/plain/map.tmj b/resources/themes/plain/map.tmj new file mode 100644 index 0000000000..daf6bc8a2d --- /dev/null +++ b/resources/themes/plain/map.tmj @@ -0,0 +1 @@ +{"compressionlevel":-1,"height":18,"width":28,"infinite":false,"orientation":"orthogonal","renderorder":"right-down","tilewidth":16,"tileheight":16,"type":"map","version":"1.10","tiledversion":"1.10.2","nextlayerid":8,"nextobjectid":19,"layers":[{"name":"floor","type":"tilelayer","width":28,"height":18,"x":0,"y":0,"opacity":1,"visible":true,"data":[1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,1,2,1,2,1,2,1,2,1,2,1,2,1,11,11,2,1,2,1,2,1,2,1,2,1,2,1,2,2,1,2,1,2,1,2,1,2,1,2,1,2,11,11,1,2,1,2,1,2,1,2,1,2,1,2,1]},{"name":"walls","type":"tilelayer","width":28,"height":18,"x":0,"y":0,"opacity":1,"visible":true,"data":[4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,0,3,3,3,3,3,3,3,3,3,3,3,3,3]},{"name":"furniture-below","type":"tilelayer","width":28,"height":18,"x":0,"y":0,"opacity":1,"visible":true,"data":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,13,13,0,0,12,0,0,0,12,0,0,0,16,0,0,0,14,0,0,0,14,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,15,15,15,0,0,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8,0,0,0,0,0,0,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8,9,8,0,0,0,0,0,8,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,0,0,5,0,0,5,0,0,5,0,0,5,0,0,5,0,0,0,0,0,0,0,0,0,0,0,0,8,0,0,8,0,0,8,0,0,8,0,0,8,0,0,8,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,0,0,0,0,0,0,0,0,0,0,0,5,0,0,5,0,0,5,0,0,5,0,0,5,0,0,5,0,0,0,0,0,0,0,0,0,0,0,0,8,0,0,8,0,0,8,0,0,8,0,0,8,0,0,8,0,0,0,0,0,0,0,0,0,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"name":"furniture-above","type":"tilelayer","width":28,"height":18,"x":0,"y":0,"opacity":1,"visible":true,"data":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,0,0,6,0,0,6,0,0,6,0,0,6,0,0,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,0,0,6,0,0,6,0,0,6,0,0,6,0,0,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]},{"name":"collision","type":"tilelayer","width":28,"height":18,"x":0,"y":0,"opacity":1,"visible":true,"data":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,1,0,0,0,0,0,0,1,1,0,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1]},{"name":"spawn-points","type":"objectgroup","draworder":"topdown","opacity":1,"visible":true,"x":0,"y":0,"objects":[{"id":1,"name":"desk-ceo","type":"","x":48,"y":96,"width":0,"height":0,"rotation":0,"visible":true,"point":true},{"id":2,"name":"pc-1","type":"","x":64,"y":160,"width":0,"height":0,"rotation":0,"visible":true,"point":true},{"id":3,"name":"pc-2","type":"","x":112,"y":160,"width":0,"height":0,"rotation":0,"visible":true,"point":true},{"id":4,"name":"pc-3","type":"","x":160,"y":160,"width":0,"height":0,"rotation":0,"visible":true,"point":true},{"id":5,"name":"pc-4","type":"","x":208,"y":160,"width":0,"height":0,"rotation":0,"visible":true,"point":true},{"id":6,"name":"pc-5","type":"","x":256,"y":160,"width":0,"height":0,"rotation":0,"visible":true,"point":true},{"id":7,"name":"pc-6","type":"","x":304,"y":160,"width":0,"height":0,"rotation":0,"visible":true,"point":true},{"id":8,"name":"pc-7","type":"","x":64,"y":224,"width":0,"height":0,"rotation":0,"visible":true,"point":true},{"id":9,"name":"pc-8","type":"","x":112,"y":224,"width":0,"height":0,"rotation":0,"visible":true,"point":true},{"id":10,"name":"pc-9","type":"","x":160,"y":224,"width":0,"height":0,"rotation":0,"visible":true,"point":true},{"id":11,"name":"pc-10","type":"","x":208,"y":224,"width":0,"height":0,"rotation":0,"visible":true,"point":true},{"id":12,"name":"pc-11","type":"","x":256,"y":224,"width":0,"height":0,"rotation":0,"visible":true,"point":true},{"id":13,"name":"pc-12","type":"","x":304,"y":224,"width":0,"height":0,"rotation":0,"visible":true,"point":true},{"id":14,"name":"cafe-seat-1","type":"","x":368,"y":80,"width":0,"height":0,"rotation":0,"visible":true,"point":true},{"id":15,"name":"cafe-seat-2","type":"","x":400,"y":80,"width":0,"height":0,"rotation":0,"visible":true,"point":true},{"id":16,"name":"cafe-seat-3","type":"","x":384,"y":64,"width":0,"height":0,"rotation":0,"visible":true,"point":true},{"id":17,"name":"cafe-seat-4","type":"","x":384,"y":96,"width":0,"height":0,"rotation":0,"visible":true,"point":true},{"id":18,"name":"entrance","type":"","x":208,"y":256,"width":0,"height":0,"rotation":0,"visible":true,"point":true}]},{"name":"zones","type":"objectgroup","draworder":"topdown","opacity":1,"visible":true,"x":0,"y":0,"objects":[]}],"tilesets":[{"columns":8,"firstgid":1,"image":"../tilesets/plain.png","imageheight":32,"imagewidth":128,"margin":0,"name":"plain","spacing":0,"tilecount":16,"tilewidth":16,"tileheight":16}]} \ No newline at end of file diff --git a/resources/themes/plain/theme.json b/resources/themes/plain/theme.json new file mode 100644 index 0000000000..c176c64e3a --- /dev/null +++ b/resources/themes/plain/theme.json @@ -0,0 +1,97 @@ +{ + "schema": 1, + "id": "plain", + "name": "Plain", + "version": "1.0.0", + "author": "agent-stage contributors", + "license": "MIT", + "blurb": "A quiet, original default floor", + "swatch": "#2c2e3a", + "copy": { + "orchestratorName": "Orchestrator" + }, + "map": "map.tmj", + "tilesets": [ + { + "file": "tilesets/plain.png", + "embedded": true + } + ], + "seats": { + "primary": [ + "desk-ceo", + "pc-1", + "pc-2", + "pc-3", + "pc-4", + "pc-5", + "pc-6", + "pc-7", + "pc-8", + "pc-9", + "pc-10", + "pc-11", + "pc-12" + ], + "cafe": [ + "cafe-seat-1", + "cafe-seat-2", + "cafe-seat-3", + "cafe-seat-4" + ], + "cafeStands": [], + "facing": { + "desk-ceo": "up", + "pc-1": "up", + "pc-2": "up", + "pc-3": "up", + "pc-4": "up", + "pc-5": "up", + "pc-6": "up", + "pc-7": "up", + "pc-8": "up", + "pc-9": "up", + "pc-10": "up", + "pc-11": "up", + "pc-12": "up", + "cafe-seat-1": "right", + "cafe-seat-2": "left", + "cafe-seat-3": "down", + "cafe-seat-4": "up" + } + }, + "anchors": { + "calendar": { + "x": 6, + "y": 1 + }, + "boards": { + "x": 10, + "y": 1 + }, + "clock": { + "x": 14, + "y": 1 + } + }, + "errandSpots": [], + "monitor": { + "offTopLeftGid": 6, + "onGids": [ + [ + 7, + 0, + 0 + ] + ] + }, + "palette": { + "background": "#14151c", + "noteColors": { + "todo": "#f2df8a", + "doing": "#9ecbf0", + "blocked": "#f0a3a3", + "done": "#a8e0b0" + } + } +} diff --git a/resources/themes/plain/tilesets/plain.png b/resources/themes/plain/tilesets/plain.png new file mode 100644 index 0000000000..dd7264f227 Binary files /dev/null and b/resources/themes/plain/tilesets/plain.png differ diff --git a/scripts/theme-packs/new.cjs b/scripts/theme-packs/new.cjs new file mode 100644 index 0000000000..4957c8abf4 --- /dev/null +++ b/scripts/theme-packs/new.cjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +'use strict'; +/** + * Scaffold a new theme pack by copying the built-in `plain` pack. + * + * npm run theme:new -- my-theme + * npm run theme:new -- my-theme path/to/themes-folder + * + * The pack lands in / (default ./my-themes/). Point the app at the + * folder with MD_THEMES_DIR or Settings → Theme → Extra themes folder, edit the + * files, and check them with `npm run theme:validate -- `. + */ +const fs = require('node:fs'); +const path = require('node:path'); + +const id = process.argv[2]; +const destRoot = path.resolve(process.argv[3] || 'my-themes'); +if (!id || !/^[a-z0-9][a-z0-9-]{1,39}$/.test(id) || ['office', 'default', 'none'].includes(id)) { + console.error('usage: npm run theme:new -- [folder] (id: lowercase letters, digits, dashes)'); + process.exit(2); +} +const src = path.resolve(__dirname, '..', '..', 'resources', 'themes', 'plain'); +const dest = path.join(destRoot, id); +if (fs.existsSync(dest)) { + console.error(`${dest} already exists`); + process.exit(1); +} +fs.cpSync(src, dest, { recursive: true }); + +const file = path.join(dest, 'theme.json'); +const manifest = JSON.parse(fs.readFileSync(file, 'utf8')); +manifest.id = id; +manifest.name = id.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +manifest.version = '0.1.0'; +manifest.author = 'you'; +manifest.license = 'MIT'; +manifest.blurb = 'My theme'; +fs.writeFileSync(file, JSON.stringify(manifest, null, 2) + '\n'); + +console.log(`Created ${dest}`); +console.log(`Next: edit theme.json / map.tmj, then npm run theme:validate -- "${dest}"`); +console.log(`Load it: MD_THEMES_DIR="${destRoot}" npm run dev (or set the folder in Settings)`); diff --git a/scripts/theme-packs/plain/gen_pack.py b/scripts/theme-packs/plain/gen_pack.py new file mode 100644 index 0000000000..80b3e34ba9 --- /dev/null +++ b/scripts/theme-packs/plain/gen_pack.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Generate the built-in `plain` theme pack: resources/themes/plain/. + +Everything here is original: flat geometric tiles drawn with Pillow and a small +hand-laid floor plan. No third-party art, fonts or tilesets are used, so the pack +can ship in the repo under the project's MIT licence. + + python scripts/theme-packs/plain/gen_pack.py + +Re-running overwrites resources/themes/plain/{theme.json,map.tmj,tilesets/plain.png}. +Edit the constants below to reshape the floor; run the validator afterwards +(`npm run theme:validate -- resources/themes/plain`). +""" +import json +import os +from PIL import Image, ImageDraw + +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', '..')) +OUT = os.path.join(ROOT, 'resources', 'themes', 'plain') + +T = 16 # tile size +COLS = 8 # tileset columns +W, H = 28, 18 # map size in tiles + +# ── tile gids (1-based, in the order they are painted into the atlas) ──────── +FLOOR_A, FLOOR_B, WALL, WALL_TOP, DESK, MON_OFF, MON_ON, CHAIR = 1, 2, 3, 4, 5, 6, 7, 8 +TABLE, PLANT, MAT, BOARD, SHELF, WINDOW, COUNTER, CLOCK = 9, 10, 11, 12, 13, 14, 15, 16 +TILECOUNT = 16 + + +def painter(fn): + img = Image.new('RGBA', (T, T), (0, 0, 0, 0)) + fn(ImageDraw.Draw(img)) + return img + + +def rect(d, x0, y0, x1, y1, c): + d.rectangle([x0, y0, x1, y1], fill=c) + + +def tiles(): + t = [] + # floors: dark slate, subtle two-tone checker so movement reads + t.append(painter(lambda d: (rect(d, 0, 0, 15, 15, (40, 42, 54, 255)), rect(d, 0, 0, 0, 15, (46, 48, 62, 255)), rect(d, 0, 0, 15, 0, (46, 48, 62, 255))))) + t.append(painter(lambda d: (rect(d, 0, 0, 15, 15, (44, 46, 59, 255)), rect(d, 0, 0, 0, 15, (50, 52, 66, 255)), rect(d, 0, 0, 15, 0, (50, 52, 66, 255))))) + # wall face + wall top + t.append(painter(lambda d: (rect(d, 0, 0, 15, 15, (86, 90, 112, 255)), rect(d, 0, 0, 15, 1, (110, 115, 140, 255)), rect(d, 0, 14, 15, 15, (62, 65, 84, 255))))) + t.append(painter(lambda d: (rect(d, 0, 0, 15, 15, (58, 61, 80, 255)), rect(d, 0, 12, 15, 15, (74, 78, 100, 255))))) + # desk: warm wood slab with a lip + t.append(painter(lambda d: (rect(d, 0, 2, 15, 15, (150, 112, 74, 255)), rect(d, 0, 2, 15, 3, (178, 138, 96, 255)), rect(d, 0, 13, 15, 15, (112, 82, 54, 255))))) + # monitor off / on (1x1: bezel + screen) + def mon(on): + def f(d): + rect(d, 2, 2, 13, 11, (24, 24, 30, 255)) + rect(d, 3, 3, 12, 10, (74, 224, 150, 255) if on else (36, 40, 52, 255)) + if on: + for y in (5, 7, 9): + rect(d, 4, y, 9, y, (28, 90, 62, 255)) + rect(d, 6, 12, 9, 13, (24, 24, 30, 255)) + rect(d, 4, 14, 11, 15, (24, 24, 30, 255)) + return painter(f) + t.append(mon(False)) + t.append(mon(True)) + # chair: seat disc with a back rest + t.append(painter(lambda d: (rect(d, 3, 2, 12, 5, (70, 110, 190, 255)), rect(d, 2, 6, 13, 13, (92, 136, 220, 255)), rect(d, 2, 12, 13, 13, (60, 94, 160, 255))))) + # table + t.append(painter(lambda d: (d.ellipse([1, 2, 14, 14], fill=(206, 190, 156, 255)), d.ellipse([3, 4, 12, 12], fill=(222, 208, 178, 255))))) + # plant + t.append(painter(lambda d: (rect(d, 5, 10, 10, 15, (138, 88, 62, 255)), d.ellipse([2, 1, 13, 11], fill=(72, 170, 98, 255)), d.ellipse([5, 3, 10, 8], fill=(104, 204, 128, 255))))) + # door mat + t.append(painter(lambda d: (rect(d, 0, 0, 15, 15, (84, 70, 94, 255)), [rect(d, 0, y, 15, y, (104, 88, 116, 255)) for y in (3, 7, 11, 15)]))) + # board (calendar / task board): paper on the wall + t.append(painter(lambda d: (rect(d, 1, 1, 14, 12, (236, 232, 220, 255)), rect(d, 1, 1, 14, 2, (208, 90, 80, 255)), [rect(d, 3, y, 12, y, (150, 146, 138, 255)) for y in (5, 7, 9)]))) + # shelf + t.append(painter(lambda d: (rect(d, 0, 0, 15, 15, (96, 70, 52, 255)), rect(d, 1, 1, 14, 6, (60, 44, 34, 255)), rect(d, 1, 9, 14, 14, (60, 44, 34, 255)), + [rect(d, x, 2, x + 1, 6, c) for x, c in ((2, (200, 90, 90, 255)), (5, (90, 150, 210, 255)), (8, (230, 190, 90, 255)), (11, (110, 190, 130, 255)))]))) + # window + t.append(painter(lambda d: (rect(d, 1, 1, 14, 12, (150, 190, 230, 255)), rect(d, 7, 1, 8, 12, (230, 232, 238, 255)), rect(d, 1, 6, 14, 7, (230, 232, 238, 255)), rect(d, 1, 12, 14, 13, (190, 192, 204, 255))))) + # counter + t.append(painter(lambda d: (rect(d, 0, 4, 15, 15, (170, 172, 184, 255)), rect(d, 0, 4, 15, 5, (208, 210, 222, 255)), rect(d, 0, 13, 15, 15, (120, 122, 136, 255))))) + # clock + t.append(painter(lambda d: (d.ellipse([2, 2, 13, 13], fill=(236, 232, 220, 255)), d.ellipse([2, 2, 13, 13], outline=(60, 60, 70, 255)), rect(d, 7, 4, 8, 8, (40, 40, 50, 255)), rect(d, 8, 7, 11, 8, (40, 40, 50, 255))))) + assert len(t) == TILECOUNT + return t + + +def build_tileset(): + rows = (TILECOUNT + COLS - 1) // COLS + atlas = Image.new('RGBA', (COLS * T, rows * T), (0, 0, 0, 0)) + for i, tile in enumerate(tiles()): + atlas.paste(tile, ((i % COLS) * T, (i // COLS) * T)) + return atlas, COLS * T, rows * T + + +# ── floor plan ─────────────────────────────────────────────────────────────── +def layer(name, data): + return {'name': name, 'type': 'tilelayer', 'width': W, 'height': H, 'x': 0, 'y': 0, 'opacity': 1, 'visible': True, 'data': data} + + +def build_map(img_w, img_h): + grid = lambda: [0] * (W * H) + floor, walls, below, above, coll = grid(), grid(), grid(), grid(), grid() + at = lambda x, y: y * W + x + + for y in range(H): + for x in range(W): + floor[at(x, y)] = FLOOR_A if (x + y) % 2 == 0 else FLOOR_B + + # outer walls (top two rows, sides, bottom) with a door gap at the bottom + door = (13, 14) + for x in range(W): + walls[at(x, 0)] = WALL_TOP; coll[at(x, 0)] = 1 + walls[at(x, 1)] = WALL; coll[at(x, 1)] = 1 + if x not in door: + walls[at(x, H - 1)] = WALL; coll[at(x, H - 1)] = 1 + for x in door: + floor[at(x, H - 1)] = MAT + floor[at(x, H - 2)] = MAT + for y in range(2, H): + for x in (0, W - 1): + walls[at(x, y)] = WALL; coll[at(x, y)] = 1 + + # wall dressing (drawn over the wall face). Anchors: calendar, boards, clock. + anchors = {'calendar': (6, 1), 'boards': (10, 1), 'clock': (14, 1)} + below[at(*anchors['calendar'])] = BOARD + below[at(*anchors['boards'])] = BOARD + below[at(*anchors['clock'])] = CLOCK + for x in (2, 3): + below[at(x, 1)] = SHELF + for x in (18, 22): + below[at(x, 1)] = WINDOW + for x in (24, 25, 26): + below[at(x, 2)] = COUNTER; coll[at(x, 2)] = 1 + + # desks: monitor (above), desk (below), chair/seat one tile south of the desk + seats = [] # (name, x, y) + def desk(name, x, y): + above[at(x, y - 2)] = MON_OFF; coll[at(x, y - 2)] = 1 + below[at(x, y - 1)] = DESK; coll[at(x, y - 1)] = 1 + below[at(x, y)] = CHAIR + seats.append((name, x, y)) + + desk('desk-ceo', 3, 6) + n = 1 + for row_y in (10, 14): + for i in range(6): + desk(f'pc-{n}', 4 + 3 * i, row_y) + n += 1 + + # small cafe corner: one table, four seats around it + table = (24, 5) + below[at(*table)] = TABLE; coll[at(*table)] = 1 + cafe = [('cafe-seat-1', 23, 5, 'right'), ('cafe-seat-2', 25, 5, 'left'), ('cafe-seat-3', 24, 4, 'down'), ('cafe-seat-4', 24, 6, 'up')] + + for _, cx, cy, _ in cafe: + below[at(cx, cy)] = CHAIR + + # plants in the corners + for x, y in ((1, 3), (26, 15), (1, 15), (20, 12)): + below[at(x, y)] = PLANT; coll[at(x, y)] = 1 + + entrance = (13, H - 2) + objs = [] + oid = 1 + def point(name, x, y): + nonlocal oid + objs.append({'id': oid, 'name': name, 'type': '', 'x': x * T, 'y': y * T, 'width': 0, 'height': 0, 'rotation': 0, 'visible': True, 'point': True}) + oid += 1 + for name, x, y in seats: + point(name, x, y) + for name, x, y, _ in cafe: + point(name, x, y) + point('entrance', *entrance) + + tm = { + 'compressionlevel': -1, 'height': H, 'width': W, 'infinite': False, 'orientation': 'orthogonal', + 'renderorder': 'right-down', 'tilewidth': T, 'tileheight': T, 'type': 'map', 'version': '1.10', + 'tiledversion': '1.10.2', 'nextlayerid': 8, 'nextobjectid': oid, + 'layers': [ + layer('floor', floor), layer('walls', walls), layer('furniture-below', below), + layer('furniture-above', above), layer('collision', coll), + {'name': 'spawn-points', 'type': 'objectgroup', 'draworder': 'topdown', 'opacity': 1, 'visible': True, 'x': 0, 'y': 0, 'objects': objs}, + {'name': 'zones', 'type': 'objectgroup', 'draworder': 'topdown', 'opacity': 1, 'visible': True, 'x': 0, 'y': 0, 'objects': []}, + ], + 'tilesets': [{ + 'columns': COLS, 'firstgid': 1, 'image': '../tilesets/plain.png', 'imageheight': img_h, 'imagewidth': img_w, + 'margin': 0, 'name': 'plain', 'spacing': 0, 'tilecount': TILECOUNT, 'tilewidth': T, 'tileheight': T, + }], + } + + facing = {name: 'up' for name, _, _ in seats} + facing.update({name: f for name, _, _, f in cafe}) + manifest = { + 'schema': 1, 'id': 'plain', 'name': 'Plain', 'version': '1.0.0', + 'author': 'agent-stage contributors', 'license': 'MIT', + 'blurb': 'A quiet, original default floor', 'swatch': '#2c2e3a', + 'map': 'map.tmj', + 'tilesets': [{'file': 'tilesets/plain.png', 'embedded': True}], + 'seats': { + 'primary': [s[0] for s in seats], + 'cafe': [c[0] for c in cafe], + 'cafeStands': [], + 'facing': facing, + }, + 'anchors': {k: {'x': v[0], 'y': v[1]} for k, v in anchors.items()}, + 'errandSpots': [], + 'monitor': {'offTopLeftGid': MON_OFF, 'onGids': [[MON_ON, 0, 0]]}, + 'palette': { + 'background': '#14151c', + 'noteColors': {'todo': '#f2df8a', 'doing': '#9ecbf0', 'blocked': '#f0a3a3', 'done': '#a8e0b0'}, + }, + } + return tm, manifest + + +def main(): + os.makedirs(os.path.join(OUT, 'tilesets'), exist_ok=True) + atlas, iw, ih = build_tileset() + atlas.save(os.path.join(OUT, 'tilesets', 'plain.png')) + tm, manifest = build_map(iw, ih) + with open(os.path.join(OUT, 'map.tmj'), 'w', encoding='utf8', newline='\n') as f: + json.dump(tm, f, separators=(',', ':')) + with open(os.path.join(OUT, 'theme.json'), 'w', encoding='utf8', newline='\n') as f: + json.dump(manifest, f, indent=2) + f.write('\n') + print('wrote', OUT) + + +if __name__ == '__main__': + main() diff --git a/scripts/theme-packs/validate.cjs b/scripts/theme-packs/validate.cjs new file mode 100644 index 0000000000..a8f636b5d0 --- /dev/null +++ b/scripts/theme-packs/validate.cjs @@ -0,0 +1,50 @@ +#!/usr/bin/env node +'use strict'; +/** + * Validate a theme pack folder exactly the way the app does. + * + * npm run theme:validate -- resources/themes/plain + * npm run theme:validate -- path/to/my-pack path/to/another-pack + * + * Prints every error and warning with its code and field, and exits 1 if any pack + * has an error. Warnings alone exit 0 (the app loads a pack with warnings; the + * affected field is dropped). + */ +const fs = require('node:fs'); +const path = require('node:path'); +const loadTs = require('../../test/load-ts.cjs'); + +const { validatePack } = loadTs('src/shared/themePack.ts'); + +function readTree(dir, base = dir, out = {}) { + for (const name of fs.readdirSync(dir)) { + const full = path.join(dir, name); + const st = fs.statSync(full); + if (st.isDirectory()) readTree(full, base, out); + else out[path.relative(base, full).split(path.sep).join('/')] = new Uint8Array(fs.readFileSync(full)); + } + return out; +} + +const dirs = process.argv.slice(2).filter((a) => !a.startsWith('-')); +if (dirs.length === 0) { + console.error('usage: npm run theme:validate -- [ ...]'); + process.exit(2); +} + +let failed = false; +for (const dir of dirs) { + const abs = path.resolve(dir); + if (!fs.existsSync(path.join(abs, 'theme.json'))) { + console.error(`${dir}: no theme.json (is this a pack folder?)`); + failed = true; + continue; + } + const result = validatePack({ dirName: path.basename(abs), files: readTree(abs) }); + const errors = result.issues.filter((i) => i.level === 'error'); + const warns = result.issues.filter((i) => i.level === 'warn'); + console.log(`${dir}: ${errors.length} error(s), ${warns.length} warning(s)${result.pack ? '' : ' — pack REJECTED'}`); + for (const i of result.issues) console.log(` ${i.level === 'error' ? 'ERROR' : 'warn '} [${i.code}] ${i.path ?? ''} ${i.message}`); + if (errors.length > 0 || !result.pack) failed = true; +} +process.exit(failed ? 1 : 0); diff --git a/scripts/verify-keepalive-catchup.mjs b/scripts/verify-keepalive-catchup.mjs index 0e12727f8e..a6065deee3 100644 --- a/scripts/verify-keepalive-catchup.mjs +++ b/scripts/verify-keepalive-catchup.mjs @@ -18,7 +18,7 @@ // the old onSystemResume re-armed the scheduler + beats but NOT the router, // so an outbox that piled up while asleep stayed undelivered after wake. // 6. FIX: the new onSystemResume re-arms the router AND flushes the backlog -// immediately on wake (no tick wait) — god->worker mail delivers on resume. +// immediately on wake (no tick wait) — orchestrator->worker mail delivers on resume. // 7. FIX: the re-armed router keeps draining subsequent mail at its cadence. // ---- fake clock + timer queue (libuv-style monotonic; we control advancement) -- @@ -97,13 +97,13 @@ onSystemResume(m); // 'unlock-screen' immediately after — must advance(0); ok(fireCount === 1, `resume+unlock collapse to ONE catch-up fire (idempotent) -> fireCount=${fireCount}`); -// ---- router re-arm on wake (the god->worker delivery fix) ----------------------- +// ---- router re-arm on wake (the orchestrator->worker delivery fix) ----------------------- // Mirror of the hive message router: a setInterval that drains every agent's // outbox into recipient inboxes (hive.startRouter/stopRouter/routeOnce). Like the // always-on beats it freezes across true system sleep; the fix re-arms it AND // flushes the backlog in onSystemResume's new router block. const ROUTER_MS = 1500; -let queue = 0; // outbox messages awaiting routing (e.g. god's pile-up) +let queue = 0; // outbox messages awaiting routing (e.g. orchestrator's pile-up) let delivered = 0; // messages routed into recipient inboxes let routerTimer = null; const routeOnce = () => { const n = queue; queue = 0; delivered += n; return n; }; // mirrors hive.routeOnce() @@ -118,7 +118,7 @@ queue = 1; advance(ROUTER_MS); ok(delivered === 1, `awake: router drains a freshly-written outbox within one tick -> delivered=${delivered}`); stopRouter(); // true system sleep halts the libuv interval (dead until re-armed) -queue += 3; // god's outbox accumulates while the floor is asleep +queue += 3; // orchestrator's outbox accumulates while the floor is asleep advance(100 * ROUTER_MS); // wall-clock passes; the frozen timer never fires ok(delivered === 1, `asleep: frozen router does NOT drain the backlog -> delivered=${delivered}`); diff --git a/src/main/appendLog.ts b/src/main/appendLog.ts new file mode 100644 index 0000000000..63868c5b4e --- /dev/null +++ b/src/main/appendLog.ts @@ -0,0 +1,187 @@ +/** + * LOG-STALL-AV (Jim, MAIL-AV-152; 1.1.53): the hive's append-only files (log.jsonl and + * cost-ledger.jsonl), written through a KEPT-OPEN descriptor and rotated at a size cap. + * + * WHY. Every row used to be an appendFileSync: open, append, close, on Electron main. The + * antivirus rescans the file on each open/close, and the cost grows with its size. Measured + * under the live BitDefender: ~390-460 ms and ~0.9 scanner CPU-s per row on the 70 MB log, + * ~30 rows per delivered message = seconds of frozen main and ~25 scanner CPU-s per message. + * A write through a descriptor that stays open costs 0.01-0.06 ms and triggers no rescan. + * + * SYNCHRONOUS ON PURPOSE (writeSync on the open fd, not a write stream). The cost was the + * open/close scan, not the synchronicity, so a kept-open fd removes it while keeping every + * property the old append had: rows land in order, a reader (logTail, a test) sees a row the + * moment appendLog returns, and a crash or quit loses nothing (no buffer to flush). + * + * KEEP-OPEN is opt-in in this fork (`keepOpen: true`; the app enables it at startup via + * HiveManager.keepAppendFilesOpen). A holder must CLOSE before deleting or copying the folder + * (HiveManager.closeAppendFiles), since Windows cannot remove a directory while a file in it is + * open. The default opens, writes and closes per row (the old cost, on a bounded file). + * + * ROTATION bounds what any remaining scan (a reopen, a tail reader, a backup tool) can cost. + * At the cap the live file is closed and renamed to `..jsonl`; the next row + * reopens a fresh live file. Rotated files are immutable. `keep` bounds how many are kept + * (the oldest are deleted); the cost ledger keeps all (the lifetime cost is folded from it). + * A pre-existing file already over the cap when first opened, before any rotation ever ran + * (today's 74 MB log), is rotated to `.legacy-.jsonl`, which is NEVER deleted: + * that history is the Human's. (Once rotated files exist, an oversize live file is one whose + * rename was busy; it rotates normally and is pruned like any other.) + * + * At a rotation the live file is recreated at once (a reader never finds it missing), and the + * old descriptor is closed ASYNCHRONOUSLY: the scan the antivirus runs at that close (~300 ms + * at 8 MB) happens off the main thread. The rename works with the old descriptor still open + * (Node opens with share-delete on Windows). + */ +import { close, closeSync, fstatSync, openSync, readdirSync, renameSync, rmSync, writeSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; + +/** Rotate a live append file at this size. */ +export const APPEND_ROTATE_BYTES = 8 * 1024 * 1024; +/** How often a kept-open file is checked for having been deleted underneath it. */ +export const APPEND_LINK_CHECK_MS = 1_000; +/** Rotated log files kept (the legacy file is extra and never deleted). */ +export const LOG_KEEP_ROTATED = 8; + +export interface RotatedFile { path: string; stamp: number; legacy: boolean } + +/** `.jsonl` -> its rotated siblings, oldest first (the live file is not included). */ +export function rotatedFiles(livePath: string): RotatedFile[] { + const dir = dirname(livePath); + const base = basename(livePath).replace(/\.jsonl$/, ''); + const re = new RegExp(`^${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.(legacy-)?(\\d+)\\.jsonl$`); + let names: string[]; + try { names = readdirSync(dir); } catch { return []; } + const out: RotatedFile[] = []; + for (const n of names) { + const m = re.exec(n); + if (m) out.push({ path: join(dir, n), stamp: Number(m[2]), legacy: !!m[1] }); + } + return out.sort((a, b) => a.stamp - b.stamp || (a.legacy === b.legacy ? 0 : a.legacy ? -1 : 1)); +} + +/** Every file of a rotated set in write order: rotated (oldest first), then the live one. */ +export function filesInOrder(livePath: string): string[] { + return [...rotatedFiles(livePath).map((r) => r.path), livePath]; +} + +export interface AppendFileOptions { + capBytes?: number; + /** Rotated files kept; Infinity keeps all. */ + keep?: number; + now?: () => number; + /** Keep the descriptor open between rows; the default (false) opens, writes and closes per + * row, so a caller that deletes the folder is never blocked by a held file. The app opts in. */ + keepOpen?: boolean; + /** For tests: count the opens (each one is what the antivirus scans). */ + onOpen?: () => void; +} + +export class AppendFile { + private fd: number | null = null; + private size = 0; + private firstOpen = true; + /** When the kept-open file was last checked for having been deleted underneath us. */ + private linkCheckedAt = 0; + /** After a rotation that could not rename (the file is busy), wait this far before retrying. */ + private nextRotateAt = 0; + private readonly cap: number; + private readonly keep: number; + private readonly now: () => number; + + constructor(readonly path: string, private readonly opts: AppendFileOptions = {}) { + this.cap = opts.capBytes ?? APPEND_ROTATE_BYTES; + this.keep = opts.keep ?? LOG_KEEP_ROTATED; + this.now = opts.now ?? Date.now; + } + + /** Append one already-serialised line (must end in \n). Best-effort: never throws. */ + append(line: string): void { + try { + // DELETED UNDER US (Jim, PERF-153-FINAL residual): a kept-open file that someone unlinked + // takes writes into a ghost (nlink 0) that nobody can read, and the path cannot be + // reopened while we hold it. Checked at most once a second: an fstat on the open fd, + // which no antivirus scans. Then close the ghost and reopen by path. + if (this.fd !== null) { + const t = this.now(); + if (t - this.linkCheckedAt >= APPEND_LINK_CHECK_MS) { + this.linkCheckedAt = t; + if (fstatSync(this.fd).nlink === 0) this.closeFd(); + } + } + if (this.fd === null) this.open(); + if (this.fd === null) return; + const n = writeSync(this.fd, line, null, 'utf8'); + this.size += n; + if (this.size >= Math.max(this.cap, this.nextRotateAt)) this.rotate(false); + else if (!this.opts.keepOpen) this.closeFd(); + } catch { + // A failed write drops the descriptor; the next row reopens (and so recovers from a + // file removed or replaced underneath us). + this.closeFd(); + } + } + + /** Close the descriptor (quit, a hive switch). The next append reopens. */ + close(): void { + this.closeFd(); + } + + /** Is a descriptor held right now? (diagnostics, tests) */ + get isOpen(): boolean { + return this.fd !== null; + } + + private open(): void { + this.fd = openSync(this.path, 'a'); + this.opts.onOpen?.(); + this.size = fstatSync(this.fd).size; + this.nextRotateAt = 0; + if (this.firstOpen) { + this.firstOpen = false; + // A file already over the cap at first open predates rotation: keep ALL of it, under a + // name retention never deletes, and start a fresh live file. + if (this.size >= this.cap) this.rotate(rotatedFiles(this.path).length === 0); + } + } + + private rotate(legacy: boolean): void { + const old = this.fd; + this.fd = null; + const stamp = this.now(); + const base = this.path.replace(/\.jsonl$/, ''); + const target = `${base}.${legacy ? 'legacy-' : ''}${stamp}.jsonl`; + try { + renameSync(this.path, target); + } catch { + // Busy (another process holds it without share-delete): keep appending, retry later. + if (old !== null) { try { closeSync(old); } catch { /* gone */ } } + this.fd = openSync(this.path, 'a'); + this.opts.onOpen?.(); + this.size = fstatSync(this.fd).size; + this.nextRotateAt = this.size + Math.ceil(this.cap / 8); + return; + } + // The rotated file is complete: its close (and the scan that comes with it) runs off main. + if (old !== null) close(old, () => { /* best-effort */ }); + // N3: recreate the live file now, so a reader (the heartbeat's mtime) never finds it missing. + this.fd = openSync(this.path, 'a'); + this.opts.onOpen?.(); + this.size = 0; + this.nextRotateAt = 0; + if (!this.opts.keepOpen) this.closeFd(); + if (Number.isFinite(this.keep)) this.prune(); + } + + private prune(): void { + const rotated = rotatedFiles(this.path).filter((r) => !r.legacy); + for (const r of rotated.slice(0, Math.max(0, rotated.length - this.keep))) { + try { rmSync(r.path, { force: true }); } catch { /* next rotation retries */ } + } + } + + private closeFd(): void { + if (this.fd === null) return; + try { closeSync(this.fd); } catch { /* already gone */ } + this.fd = null; + } +} diff --git a/src/main/closingTime.ts b/src/main/closingTime.ts index 98b422317f..6b6de193ed 100644 --- a/src/main/closingTime.ts +++ b/src/main/closingTime.ts @@ -8,10 +8,10 @@ * manager locks the door. * * 1. The human clicks "closing time" in the quit dialog. - * 2. We mail the god agent a shutdown brief: broadcast closing time to the + * 2. We mail the orchestrator a shutdown brief: broadcast closing time to the * team; every worker commits/parks WIP, appends state + next steps to * its memory.md, then replies with subject CLOSING-TIME-ACK. - * 3. The god waits for every ACK (the harness shows live progress by + * 3. The orchestrator waits for every ACK (the harness shows live progress by * watching the same inbox traffic), saves its own memory, and sends a * message with subject CLOSING-TIME-COMPLETE. * 4. The router observer spots that message → the app tears down and quits. @@ -46,13 +46,13 @@ const COMPLETE_RE = /CLOSING[-_\s]*TIME[-_\s]*COMPLETE/i; /** How long to wait before surfacing "this is taking long — force quit?". * Compaction or a long tool call can easily hold an ACK for a few minutes. */ const TIMEOUT_MS = 6 * 60_000; -/** Grace after COMPLETE before tearing down, so the god's final commit/log +/** Grace after COMPLETE before tearing down, so the orchestrator's final commit/log * writes land on disk and the floor visibly concludes. */ const TEARDOWN_GRACE_MS = 2_500; export class ClosingTimeController { private active = false; - private godId = 'god'; + private orchestratorId = 'orchestrator'; private workers = new Set(); private acked = new Set(); private timeoutTimer: NodeJS.Timeout | null = null; @@ -66,7 +66,7 @@ export class ClosingTimeController { * registry-based roster waits on ghosts that can never ACK. */ private getLiveAgentIds: () => string[], private getWebContents: () => WebContents | null, - /** Called once the god concluded — runs the real teardown + app.quit(). */ + /** Called once the orchestrator concluded — runs the real teardown + app.quit(). */ private onConcluded: () => void, /** Mid-run steering (#7C.2): lets closing time reach DEEPLY BUSY agents at * their next hook boundary instead of waiting for the Stop-hook inbox @@ -79,7 +79,7 @@ export class ClosingTimeController { } /** Kick off the protocol. Returns an error string when the floor cannot run - * it (no live god agent) so the UI can fall back to the hard quit. */ + * it (no live orchestrator) so the UI can fall back to the hard quit. */ start(): { ok: boolean; error?: string } { if (this.active) { // Re-pressed while running (e.g. from the timeout view): keep waiting. @@ -88,18 +88,18 @@ export class ClosingTimeController { return { ok: true }; } const reg = this.hive.registry(); - this.godId = reg.godId ?? 'god'; + this.orchestratorId = reg.orchestratorId ?? 'orchestrator'; const live = new Set(this.getLiveAgentIds()); - if (!reg.agents[this.godId] || !live.has(this.godId)) { - return { ok: false, error: 'No orchestrator is running — closing time needs the god agent to collect the reports.' }; + if (!reg.agents[this.orchestratorId] || !live.has(this.orchestratorId)) { + return { ok: false, error: 'No orchestrator is running — closing time needs the orchestrator to collect the reports.' }; } // Only agents with a live terminal are waited on — the registry is just - // metadata here (names + god/assistant flags), never the roster source. + // metadata here (names + orchestrator/assistant flags), never the roster source. this.workers = new Set( [...live].filter((id) => { const a = reg.agents[id]; - return id !== this.godId && !!a && !a.isGod; + return id !== this.orchestratorId && !!a && !a.isOrchestrator; }) ); this.acked = new Set(); @@ -110,7 +110,7 @@ export class ClosingTimeController { .join(', ') || '(none — the floor is just you)'; this.hive.send({ - to: 'god', + to: 'orchestrator', act: 'request', subject: 'CLOSING TIME — run the shutdown protocol now', body: [ @@ -135,11 +135,11 @@ export class ClosingTimeController { // (PostToolUse/UserPromptSubmit) instead, so every live agent learns about // closing time within one tool call. Idle agents are covered by the // inbox-wake nudge; busy ones by the steer — both rails, no PTY typing. - this.control?.steer(this.godId, + this.control?.steer(this.orchestratorId, 'CLOSING TIME was pressed by the human: pause your current work at the next sensible point and drain your inbox NOW — a shutdown brief is waiting there. Coordinate the floor shutdown before anything else.'); for (const id of this.workers) { this.control?.steer(id, - 'CLOSING TIME — the office is shutting down. Finish your current step but do NOT start new work. Park or commit your work-in-progress safely, append your current state + concrete next steps to your memory.md, then reply to god with a message whose subject is exactly "CLOSING-TIME-ACK".'); + 'CLOSING TIME — the office is shutting down. Finish your current step but do NOT start new work. Park or commit your work-in-progress safely, append your current state + concrete next steps to your memory.md, then reply to orchestrator with a message whose subject is exactly "CLOSING-TIME-ACK".'); } this.armTimeout(); @@ -153,13 +153,13 @@ export class ClosingTimeController { this.cleanup(); // Drop closing-time steers that no hook boundary has consumed yet, so a // busy agent doesn't get told to shut down AFTER the human cancelled. - // Agents that already saw the note get corrected via the god (below). - this.control?.clearSteers(this.godId); + // Agents that already saw the note get corrected via the orchestrator (below). + this.control?.clearSteers(this.orchestratorId); for (const id of this.workers) this.control?.clearSteers(id); this.emitState('cancelled'); try { this.hive.send({ - to: 'god', + to: 'orchestrator', act: 'inform', subject: 'CLOSING TIME CANCELLED', body: 'The human cancelled the shutdown — disregard the closing-time protocol and resume normal operation. Any memory saves already done are a bonus, not a problem.' @@ -171,18 +171,18 @@ export class ClosingTimeController { onRouted(msg: HiveMessage, targets: string[]): void { if (!this.active) return; // A worker reporting in. Counted only for known workers, and only when the - // ACK actually reached the god (not e.g. a stray broadcast echo). - if (ACK_RE.test(msg.subject) && this.workers.has(msg.from) && targets.includes(this.godId)) { + // ACK actually reached the orchestrator (not e.g. a stray broadcast echo). + if (ACK_RE.test(msg.subject) && this.workers.has(msg.from) && targets.includes(this.orchestratorId)) { if (!this.acked.has(msg.from)) { this.acked.add(msg.from); this.emitState('progress'); } return; } - // The god concluding. COMPLETE is only honored from the god itself — a + // The orchestrator concluding. COMPLETE is only honored from the orchestrator itself — a // worker can't (accidentally or otherwise) shut down the whole floor. - if (COMPLETE_RE.test(msg.subject) && msg.from === this.godId) { - // Trust but VERIFY: the god is told to wait for every ACK, but the + if (COMPLETE_RE.test(msg.subject) && msg.from === this.orchestratorId) { + // Trust but VERIFY: the orchestrator is told to wait for every ACK, but the // whole point of closing time is that no worker loses unsaved state — // so a premature COMPLETE must not close the floor. Workers whose // terminal died mid-protocol (tab closed, crash) are excused: their @@ -195,7 +195,7 @@ export class ClosingTimeController { if (pending.length > 0) { const names = pending.map((id) => `${reg.agents[id]?.name ?? id} (${id})`).join(', '); this.hive.send({ - to: 'god', + to: 'orchestrator', act: 'refuse', subject: 'CLOSING TIME — conclusion rejected, workers still missing', body: [ diff --git a/src/main/config.ts b/src/main/config.ts index f66c854c15..f852c10dbd 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -22,6 +22,9 @@ import { type OrgTriggerConfig, type WebhookTrigger } from '../shared/triggers'; +import { normalizeThemeId, DEFAULT_THEME_ID } from '../shared/themePack'; +import { migrateConfig } from '../shared/orchestratorMigration'; +import { normalizeUsageEndpoint, DEFAULT_USAGE_TIMEOUT_MS, type UsageEndpointConfig } from '../shared/usageEndpoint'; /** A recurring auto-dispatched mission fired on an interval by the scheduler. */ export interface ScheduledMission { @@ -50,23 +53,36 @@ export interface ScheduledMission { lastFiredAt?: number; /** Mission flavor. Absent ⇒ 'dispatch' (the classic interval-dispatch mission, * e.g. the ops standup). 'heartbeat' (Lane A #1) is a context-aware beat: it - * observes live floor state, re-engages a quiet god, and ticks the circuit + * observes live floor state, re-engages a quiet orchestrator, and ticks the circuit * breaker — armed with an adaptive cadence, not a fixed setInterval. */ kind?: 'dispatch' | 'heartbeat' | 'compact'; /** Heartbeat only: a floor is "quiet" when no tracked signal (log.jsonl mtime, * inbox/outbox mtimes, any PTY output) has moved in this many ms. Default * ~5 min. NOT derived from registry.status (which never transitions in main). */ quietThresholdMs?: number; + /** TE0: suppress this mission's dispatch while the floor is provably unchanged. + * ABSENT ⇒ OFF ⇒ the pre-TE0 unconditional dispatch, so every mission that does + * not opt in keeps its exact prior behaviour. See main/standupDelta.ts for what + * "unchanged" hashes, and for the two rules that make the answer trustworthy. */ + deltaGate?: { enabled: boolean }; + /** Scheduler-owned, like `lastFiredAt`: the floor fingerprint as of the last + * DISPATCHED run, and when that was. `lastFiredAt` cannot stand in for the + * latter — it advances on suppressed ticks too (it has to, or the timer + * re-arms with zero delay). `lastDispatchAt` no longer gates anything: it is + * read only to report how long a suppressed floor has been quiet, which under + * the gate's RULE 3 is an unbounded span and therefore worth reporting. */ + lastDeltaFingerprint?: string; + lastDispatchAt?: number; } -/** The built-in hourly ops standup: god reviews who's doing what + whether tasks +/** The built-in hourly ops standup: orchestrator reviews who's doing what + whether tasks * are on track and agents are running, and every terminal's context is compacted. * Shipped enabled by default; users can toggle it off in the Command Center. */ export const OPS_STANDUP_MISSION: ScheduledMission = { id: 'ops-standup', label: 'Hourly ops standup', intervalMs: 3_600_000, - to: 'god', + to: 'orchestrator', body: 'Hourly ops standup. Review every agent: who is doing what, and confirm each ' + 'is still running (not stalled or idle-stale). Check the task board — are ' + @@ -76,7 +92,14 @@ export const OPS_STANDUP_MISSION: ScheduledMission = { 'next step, then compact and resume from the same point — so terminal ' + 'contexts stay bounded without losing work. The compaction is queued and ' + 'runs when an agent is idle, so it never interrupts work mid-step.)', - enabled: true + enabled: true, + // TE0. A standup whose only finding is "nothing changed" still costs a full + // model turn over orchestrator's whole session prefix, because the dispatch wakes him. + // The gate answers that question locally instead. There is NO periodic + // fallback: a provably unchanged floor may go indefinitely without a standup, + // which is the owner's ruling and the whole point of the gate. Stall detection + // is the HEARTBEAT's job, not this mission's — see standupDelta.ts RULE 3. + deltaGate: { enabled: true } // NO autoCompact. Compaction belongs to contextTrigger.compact and nothing else. // This flag used to live here as well, which meant a default install asked for // compaction on TWO cadences — hourly from this standup and 2-hourly from the @@ -87,11 +110,11 @@ export const OPS_STANDUP_MISSION: ScheduledMission = { /** The built-in heartbeat (Lane A #1). A context-aware beat that, each tick, * observes live floor state and — only when the floor has gone quiet — drops a - * digest into god's inbox and (if god's PTY is genuinely idle) nudges it to + * digest into orchestrator's inbox and (if orchestrator's PTY is genuinely idle) nudges it to * re-engage anyone stalled. The same beat ticks the circuit breaker. * * Shipped DISABLED by default (opt-in): unlike the standup, which only sends a - * hive message, the heartbeat types into god's PTY, so the user turns it on + * hive message, the heartbeat types into orchestrator's PTY, so the user turns it on * explicitly in the Command Center once they want active re-engagement. * `intervalMs` is the normal-cadence base; the scheduler derives a tighter beat * when an agent looks stuck and a slower one right after a re-engage. */ @@ -99,7 +122,7 @@ export const HEARTBEAT_MISSION: ScheduledMission = { id: 'heartbeat', label: 'Floor heartbeat', intervalMs: 120_000, - to: 'god', + to: 'orchestrator', body: 'Floor heartbeat: the team has gone quiet. Review the digest in your inbox, ' + 're-engage anyone stalled or blocked, and keep the board accurate — or rest ' + @@ -190,7 +213,7 @@ export interface HarnessConfig { registeredRepos: string[]; /** When true, new agents are spawned with --permission-mode bypassPermissions. */ autoMode: boolean; - /** May the orchestrator ("Michael") spin up agents on its own? + /** May the orchestrator (the orchestrator) spin up agents on its own? * * Default FALSE. Spawning an agent is a SPEND decision, so it should not * happen unprompted. The ability itself shipped in v0.4.4 with no gate at all, @@ -200,17 +223,24 @@ export interface HarnessConfig { * Off does not FAIL a queued spawn request, it declines to consume one. The * request sits in HIVE_ROOT/spawn-requests until the toggle is turned on. */ orchestratorMaySpawn: boolean; + /** Optional provider-usage endpoint polled at each standup (see src/shared/usageEndpoint.ts). */ + usageEndpoint?: UsageEndpointConfig; + /** HEAVY-JOB-SERIALIZE (Settings → Autonomy & Budgets, "Heavy jobs at once"): how many heavy + * jobs (installs, builds, full test suites, benches) the floor's agents may run at once; a + * further one is denied at PreToolUse, naming the holders. 'off' = no limit (the guard does + * nothing). Default 1. Read live on every hook: a change applies without restarting agents. */ + heavyJobsAtOnce?: number | 'off'; /** The command we run when spawning a new agent. */ defaultCommand: string; /** Default model for newly spawned agents (e.g. 'claude-sonnet-4-6[1m]'); unset = CLI default. */ defaultModel?: string; - /** Which provider powers the GOD orchestrator ("Michael"). The persona is + /** Which provider powers the orchestrator (the orchestrator). The persona is * constant; only its engine is selectable. Default 'claude'. Eligible providers * are those that can receive inbox (claude/codex/antigravity/qwen). */ - godProvider?: AgentProvider; - /** The model GOD runs on. Unset falls back to the provider preset's - * `recommendedOrchestratorModel`, then MODEL_GOD. Default 'claude-opus-4-8'. */ - godModel?: string; + orchestratorProvider?: AgentProvider; + /** The model ORCHESTRATOR runs on. Unset falls back to the provider preset's + * `recommendedOrchestratorModel`, then MODEL_ORCHESTRATOR. Default 'claude-opus-4-8'. */ + orchestratorModel?: string; /** Per-server consent state for the default MCP bundle, keyed by catalog id. * Seeded from MCP_CATALOG (safe-readonly ON, write/secret OFF); the user flips * these in Settings. A server is wired into an agent only when enabled here. */ @@ -254,7 +284,7 @@ export interface HarnessConfig { /** Passed to every spawned agent as `--max-turns ` when set; unset = no cap * (Claude Code's default). A coarse runaway guard independent of the breaker. */ maxTurns?: number; - /** Max concurrent god-triggered ephemeral Slack workers; extra spawn-requests + /** Max concurrent orchestrator-triggered ephemeral Slack workers; extra spawn-requests * wait in the queue (natural backpressure, a resource backstop). Default 4. */ maxConcurrentWorkers?: number; /** Minutes an ephemeral worker may produce NO output before the reaper kills it @@ -267,9 +297,9 @@ export interface HarnessConfig { * Electron safeStorage — see src/main/integrations.ts). Default []. */ integrations?: IntegrationRecord[]; /** Default per-worker TOTAL-token cap (input+output+cache) applied to every - * god-triggered ephemeral worker; a worker's own spawn-request `tokenCap` + * orchestrator-triggered ephemeral worker; a worker's own spawn-request `tokenCap` * overrides it. When the effective cap is exceeded the worker is reaped (its - * committed work preserved) and god is informed. This is PLUMBING for a later + * committed work preserved) and orchestrator is informed. This is PLUMBING for a later * budget feature: per the human directive there is NO per-worker cap today, so * the default is 0 = UNLIMITED — the mechanism is wired but never throttles * unless someone explicitly sets a positive cap (per request or here). */ @@ -298,7 +328,7 @@ export interface HarnessConfig { * and comment disagreed; the shipped behavior — enabled — wins) — * the window/PTY-ownership plumbing is always active and single-window-safe, * but the New Floor entry points (app menu item + IPC) only appear when on. - * The on-disk hive (god orchestration under harnessHome) stays process-global; + * The on-disk hive (orchestrator orchestration under harnessHome) stays process-global; * floors share it. */ multiWindow?: boolean; /** Terminal theme — mirrored into each agent's per-session Claude settings @@ -310,14 +340,15 @@ export interface HarnessConfig { * without an injected key and environments with DO_NOT_TRACK set never send * regardless of this flag. (Mirrored in preload + renderer config.) */ telemetryEnabled?: boolean; - /** Master flag for the TV-show office themes feature (Settings theme picker + - * destructive switch flow). Default false = the picker is hidden and the - * office renders as today (zero behavior change). */ + /** Deprecated and ignored: the theme picker is always available. Kept so old + * config files and the realtime allow-list still parse. */ 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'; + /** Which theme pack the pixel office renders. A missing or invalid pack falls + * back to the built-in default ('plain'). */ + officeTheme?: string; + /** Extra folder scanned for theme packs (each pack is a subfolder with a theme.json). + * Searched after /themes. See docs/theming/THEMING.md. */ + themesDir?: string; /** 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 @@ -325,7 +356,7 @@ export interface HarnessConfig { providerBaseUrls?: Partial>; /** Per-CLI-provider default model slug, used to pre-fill the model picker. */ providerDefaultModels?: Partial>; - /** Master toggle for the Slack → Michael's-queue integration. */ + /** Master toggle for the Slack → the orchestrator's-queue integration. */ slackEnabled?: boolean; /** Slack app signing secret (Basic Information → Signing Secret). Never logged. */ slackSigningSecret?: string; @@ -354,8 +385,8 @@ export interface HarnessConfig { /** Groq Whisper model id. Default 'whisper-large-v3-turbo' (fast, multilingual). */ freeflowModel?: string; - // ─── Realtime Michael (premium speech-to-speech voice orchestrator) ───────── - /** True ONLY while a Realtime Michael voice session is live: the renderer + // ─── Realtime voice (premium speech-to-speech voice orchestrator) ───────── + /** True ONLY while a Realtime voice voice session is live: the renderer * session flips this on at start() (before getUserMedia) and off at stop(). * The main-process mic permission gate reads it so the Electron media * permission is open EXACTLY while the voice loop holds the mic — never just @@ -365,7 +396,7 @@ export interface HarnessConfig { /** How long (ms) a realtime voice session may sit with no voice activity before * it auto-disconnects (the rt-9 idle guard). Default 180000 (3 min). 0 = never * auto-disconnect on idle — the spend cap remains the runaway guard. The user - * tunes this in Settings → Realtime Michael. */ + * tunes this in Settings → Realtime voice. */ realtimeIdleDisconnectMs?: number; // ─── Generic inbound webhook + status API (LEGACY, single-endpoint) ───────── @@ -426,12 +457,14 @@ const DEFAULTS: HarnessConfig = { registeredRepos: [], autoMode: true, orchestratorMaySpawn: false, + usageEndpoint: { enabled: false, url: '', timeoutMs: DEFAULT_USAGE_TIMEOUT_MS }, + heavyJobsAtOnce: 1, defaultCommand: 'claude', - godProvider: 'claude', - godModel: 'claude-opus-4-8', + orchestratorProvider: 'claude', + orchestratorModel: 'claude-opus-4-8', // Global default model for every agent that hasn't picked one explicitly — wins // over the role-based tiers (modelForRole) in the spawn handler, so all agents - // (incl. god) default to Fable 5. A per-agent model choice still overrides it. + // (incl. orchestrator) default to Fable 5. A per-agent model choice still overrides it. defaultModel: 'claude-fable-5', // Seeded from the MCP catalog so the consent defaults never drift from it // (safe-readonly ON, write/secret OFF). @@ -449,7 +482,7 @@ const DEFAULTS: HarnessConfig = { telemetryEnabled: true, multiWindow: true, tvShowOffices: false, - officeTheme: 'office', + officeTheme: DEFAULT_THEME_ID, slackEnabled: false, slackSigningSecret: undefined, slackBotToken: undefined, @@ -473,7 +506,7 @@ const DEFAULTS: HarnessConfig = { triggersMigratedV1: false, // Memory reflection — preventive; nobody is over threshold today, so it sits // dark until an agent's memory crosses one of these (the verify gate is the - // safety for the LLM step). Thresholds DECIDED by god 2026-06-06. + // safety for the LLM step). Thresholds DECIDED by orchestrator 2026-06-06. reflectEnabled: true, reflectIntervalMs: 1_800_000, reflectByteTriggerPct: 50, @@ -593,8 +626,12 @@ export function readConfig(): HarnessConfig { if (!existsSync(p)) return withTriggerDefaults({ ...DEFAULTS }); try { const raw = readFileSync(p, 'utf8'); - const parsed = JSON.parse(raw); - return normalizeStoredHomes(migrateTriggersV1(withTriggerDefaults({ ...DEFAULTS, ...parsed }))); + const parsed = migrateConfig(JSON.parse(raw)) as Partial; + const merged = withTriggerDefaults({ ...DEFAULTS, ...parsed }); + if (merged.officeTheme) { + merged.officeTheme = normalizeThemeId(merged.officeTheme); + } + return normalizeStoredHomes(migrateTriggersV1(merged)); } catch { return withTriggerDefaults({ ...DEFAULTS }); } @@ -694,6 +731,7 @@ export function writeConfig(patch: Partial): HarnessConfig { next.harnessHome = home; next.recentHives = recentHives; } + if (patch.usageEndpoint !== undefined) next.usageEndpoint = normalizeUsageEndpoint(patch.usageEndpoint); return persistConfig(next); } @@ -743,38 +781,53 @@ export function resetConfig(): HarnessConfig { /** Model ids by tier (Lane A #6.4). Kept in sync with the claude list in * src/shared/modelCatalog.json, which `agentModels()` in * src/renderer/src/store/config.ts reads. */ -const MODEL_GOD = 'claude-opus-4-8'; // orchestration — highest capability +const MODEL_ORCHESTRATOR = 'claude-opus-4-8'; // orchestration — highest capability const MODEL_WORKER = 'claude-sonnet-4-6'; // general execution const MODEL_HELPER = 'claude-haiku-4-5-20251001'; // narrow, cheap helpers /** Minimal structural shape for tiering — a subset of AgentMeta so config.ts * stays free of a hive.ts import. */ export interface RoleHint { - isGod?: boolean; + isOrchestrator?: boolean; role?: string; capabilities?: string[]; } -/** Default model for an agent given its role (Lane A #6.4): Opus for the god, +/** Default model for an agent given its role (Lane A #6.4): Opus for the orchestrator, * Haiku for narrow helpers (triage / routing / verification / formatting), * Sonnet for general workers. Returns a model id (matching the catalog) or * undefined to fall back to the CLI default. This is only a DEFAULT — an * explicit per-agent model selection always wins. */ export function modelForRole( meta: RoleHint, - config?: Pick + config?: Pick ): string | undefined { - if (meta.isGod) { - // GOD engine is selectable: an explicit godModel wins, else the chosen + if (meta.isOrchestrator) { + // ORCHESTRATOR engine is selectable: an explicit orchestratorModel wins, else the chosen // provider's recommended orchestrator model, else the legacy Opus default. - const preset = providerPreset(config?.godProvider ?? 'claude'); - return config?.godModel ?? preset.recommendedOrchestratorModel ?? MODEL_GOD; + const preset = providerPreset(config?.orchestratorProvider ?? 'claude'); + return config?.orchestratorModel ?? preset.recommendedOrchestratorModel ?? MODEL_ORCHESTRATOR; } const hay = `${meta.role ?? ''} ${(meta.capabilities ?? []).join(' ')}`.toLowerCase(); if (/\b(triage|rout|verif|lint|format|summar|classif|label)/.test(hay)) return MODEL_HELPER; return MODEL_WORKER; } +/** Resolve a hive Claude spawn model. A saved per-agent `/model` choice is + * authoritative over app-wide defaults; an explicit argv `--model` is handled + * by the caller before it calls this helper. */ +export function modelForHiveSpawn( + meta: RoleHint, + config: Pick, + persistedModel?: string +): string | undefined { + const saved = persistedModel?.trim(); + if (saved) return saved; + return meta.isOrchestrator + ? modelForRole(meta, config) + : config.defaultModel ?? modelForRole(meta, config); +} + /** Ensure harnessHome exists on disk. Expands `~` first — the onboarding wizard * lets the user type the path, and mkdir treats a literal `~` as a plain * directory name (issue #140's `ENOENT: mkdir '~/HarnessAgents'`). */ @@ -836,7 +889,7 @@ function ensureClaudeProjectTrust(home: string, cwd: string): void { // the entries it writes on Windows are keyed "C:/Users/…". Writing only the // raw Windows path ("C:\Users\…") puts the flag somewhere Claude never // reads, so the agent still hits the interactive "Accessing workspace / - // Quick safety check" dialog, which it cannot answer: god exits 1 and every + // Quick safety check" dialog, which it cannot answer: orchestrator exits 1 and every // message to it sits at "waiting". // // Both spellings are written — the normalised one is what current Claude diff --git a/src/main/costLifetime.ts b/src/main/costLifetime.ts index 27529dccf9..adda75fc95 100644 --- a/src/main/costLifetime.ts +++ b/src/main/costLifetime.ts @@ -39,7 +39,9 @@ * Read-only: this module never writes to the ledger. */ -import { createReadStream, statSync } from 'fs'; +import { createReadStream } from 'fs'; +import { open } from 'fs/promises'; +import { filesInOrder } from './appendLog'; /** Per (agent, session) fold state: closed segments plus the open one. */ interface Segment { @@ -58,8 +60,14 @@ const EPS = 1e-9; const MAX_BYTES_PER_PASS = 8 * 1024 * 1024; export class CostLedgerTotals { - /** Bytes of the ledger already folded. */ + /** Bytes of the CURRENT ledger file already folded. */ private offset = 0; + /** LOG-STALL-AV F2: the ledger rotates (every rotated file is kept), so the fold reads the + * rotated files in order, then the live one. A file is identified by its file id, which a + * rename keeps: the live file being folded when it rotates is simply continued under its new + * name. Rotated files are immutable; once read to the end they are done. */ + private curId: string | null = null; + private readonly doneIds = new Set(); /** Trailing partial line, kept as BYTES so a multi-byte character split * across a read boundary is never decoded in half. */ private tail: Buffer = Buffer.alloc(0); @@ -121,31 +129,50 @@ export class CostLedgerTotals { } private async fold(ledgerPath: string): Promise { - let size: number; - try { size = statSync(ledgerPath).size; } catch { return; } - - // Truncated or rotated underneath us: the offset now points past the end, - // so every segment we hold is suspect. Start clean. - if (size < this.offset) this.reset(); - if (size === this.offset) { this.warm = true; return; } - - const end = Math.min(size, this.offset + MAX_BYTES_PER_PASS) - 1; - const from = this.offset; - - await new Promise((resolve, reject) => { - const stream = createReadStream(ledgerPath, { start: from, end }); - stream.on('data', (chunk: string | Buffer) => { - const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; - this.consume(buf); - }); - stream.on('error', reject); - stream.on('end', () => resolve()); - }); - - this.offset = end + 1; - this.recompute(); - // Only "warm" once we have caught up to EOF; a capped pass is still behind. - if (this.offset >= size) this.warm = true; + const files = filesInOrder(ledgerPath); + let seenCur = this.curId === null; + for (let i = 0; i < files.length; i++) { + const live = i === files.length - 1; + let fh; + // An unreadable live ledger leaves the last good totals (and warmth) as they were. + // The live file is briefly absent right after a rotation: every rotated file read means + // the fold is caught up. With nothing read at all, the last good totals stand. + try { fh = await open(files[i], 'r'); } catch { if (live) { if (this.curId === null && this.doneIds.size) this.warm = true; return; } continue; } + try { + const st = await fh.stat({ bigint: true }); + const id = `${st.dev}:${st.ino}`; + const size = Number(st.size); + if (this.doneIds.has(id)) continue; + if (this.curId === null) { this.curId = id; this.offset = 0; this.tail = Buffer.alloc(0); } + if (id !== this.curId) { + // The file we were folding is gone (not a rotation: those keep the id): start clean. + if (!seenCur) { this.reset(); return this.fold(ledgerPath); } + continue; + } + seenCur = true; + // Truncated underneath us: the offset now points past the end. Start clean. + if (size < this.offset) { this.reset(); return; } + if (size > this.offset) { + const end = Math.min(size, this.offset + MAX_BYTES_PER_PASS) - 1; + const from = this.offset; + await new Promise((resolve, reject) => { + const stream = createReadStream('', { fd: fh!.fd, start: from, end, autoClose: false }); + stream.on('data', (chunk: string | Buffer) => this.consume(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)); + stream.on('error', reject); + stream.on('end', () => resolve()); + }); + this.offset = end + 1; + this.recompute(); + // A capped pass is still behind: resume here next time. + if (this.offset < size) return; + } + if (live) { this.warm = true; return; } + // A rotated file read to its end: done for good; the next file starts at 0. + this.doneIds.add(id); + this.curId = null; + this.offset = 0; + } finally { await fh.close().catch(() => {}); } + } } /** Fold one buffer, holding back any incomplete trailing line. */ @@ -189,6 +216,8 @@ export class CostLedgerTotals { private reset(): void { this.offset = 0; + this.curId = null; + this.doneIds.clear(); this.tail = Buffer.alloc(0); this.seg.clear(); this.totals = new Map(); diff --git a/src/main/godStartup.ts b/src/main/godStartup.ts new file mode 100644 index 0000000000..c093fed80c --- /dev/null +++ b/src/main/godStartup.ts @@ -0,0 +1,245 @@ +/** + * GOD-STARTUP-TOKENS R1 (1.1.79, Jim; diagnosis hive/agents/jim-mtujpe28/GOD-STARTUP-1M-TOKENS-045.md). + * + * At launch god is RESUMED (`--resume `), so its first request re-sends its + * whole previous conversation, and every step after it re-sends that again. Measured on this + * machine: 77-510K tokens per request, the first one a full cache WRITE (1.25x) whenever the + * cache had expired. The cache is lost when god's last request is older than the cache lifetime + * (1 h), when Claude Code updated itself in between, or when the model is different; a big + * context then costs its full size again on the first request. + * + * So an AUTOMATIC god resume (restore on restart; never a typed id or a person's "Restart & + * Continue") starts FRESH, with a short handoff in context (godHandoffContext), when ANY of: + * - context-over-limit the last request carried more than GOD_RESUME_MAX_CONTEXT tokens; + * - cache-expired the last request is older than GOD_RESUME_CACHE_MS; + * - cli-version-changed the transcript's Claude Code version differs from the installed one; + * - model-changed the transcript's model differs from the model this spawn runs. + * A fact that cannot be read is never a reason (the resume then goes ahead as before). + */ +import { closeSync, existsSync, fstatSync, openSync, readFileSync, readSync, realpathSync } from 'node:fs'; +import { dirname, join, sep } from 'node:path'; + +/** R1: above this many tokens of context, a resume costs more than a fresh start with a handoff. */ +export const GOD_RESUME_MAX_CONTEXT = 150_000; +/** R1: the prompt-cache lifetime; a resume after it re-writes the whole context. */ +export const GOD_RESUME_CACHE_MS = 60 * 60 * 1000; +/** How much of the transcript's end is read for the last request (a request line is small). */ +export const GOD_TRANSCRIPT_TAIL_BYTES = 512 * 1024; + +export type GodFreshReason = 'context-over-limit' | 'cache-expired' | 'cli-version-changed' | 'model-changed'; + +/** What god's last session says about its last request. Every field may be unknown (null). */ +export interface GodSessionFacts { + /** Tokens the last request carried (input + cache read + cache write) plus its output. */ + contextTokens: number | null; + /** When the last request was answered (ms). */ + lastRequestAt: number | null; + /** The Claude Code version that wrote it. */ + cliVersion: string | null; + /** The model that answered it. */ + model: string | null; +} + +/** The facts of the LAST assistant request in a Claude transcript's tail (JSONL), or all null. */ +export function godSessionFactsFromTail(tail: string): GodSessionFacts { + const none: GodSessionFacts = { contextTokens: null, lastRequestAt: null, cliVersion: null, model: null }; + const lines = tail.split('\n'); + for (let i = lines.length - 1; i >= 0; i -= 1) { + const line = lines[i].trim(); + if (!line.includes('"assistant"') || !line.includes('"usage"')) continue; + let rec: Record; + try { rec = JSON.parse(line) as Record; } catch { continue; } + if (rec.type !== 'assistant' || rec.isSidechain === true || typeof rec.message !== 'object' || rec.message === null) continue; + const msg = rec.message as Record; + const u = (typeof msg.usage === 'object' && msg.usage !== null ? msg.usage : null) as Record | null; + if (!u) continue; + const n = (k: string): number => (typeof u[k] === 'number' && Number.isFinite(u[k] as number) ? u[k] as number : 0); + const ts = typeof rec.timestamp === 'string' ? Date.parse(rec.timestamp) : NaN; + return { + contextTokens: n('input_tokens') + n('cache_read_input_tokens') + n('cache_creation_input_tokens') + n('output_tokens'), + lastRequestAt: Number.isFinite(ts) ? ts : null, + cliVersion: typeof rec.version === 'string' && rec.version.trim() ? rec.version.trim() : null, + model: typeof msg.model === 'string' && msg.model.trim() ? msg.model.trim() : null + }; + } + return none; +} + +/** The facts of a transcript file (its last GOD_TRANSCRIPT_TAIL_BYTES), or all null. */ +export function readGodSessionFacts(file: string | null): GodSessionFacts { + const none: GodSessionFacts = { contextTokens: null, lastRequestAt: null, cliVersion: null, model: null }; + if (!file) return none; + let fd: number | null = null; + try { + fd = openSync(file, 'r'); + const size = fstatSync(fd).size; + const len = Math.min(size, GOD_TRANSCRIPT_TAIL_BYTES); + const buf = Buffer.alloc(len); + readSync(fd, buf, 0, len, size - len); + return godSessionFactsFromTail(buf.toString('utf8')); + } catch { + return none; + } finally { + if (fd !== null) try { closeSync(fd); } catch { /* closed */ } + } +} + +/** A Claude model id as the cache sees it: lower case, no `[1m]` context suffix. An alias the + * CLI resolves itself (`opus`, `sonnet`, `default`) is not comparable: null. */ +export function cacheModelKey(model: string | null | undefined): string | null { + const m = (model ?? '').trim().toLowerCase().replace(/\[1m\]$/, ''); + return m.startsWith('claude-') ? m : null; +} + +/** The R1 decision. `now` and `current` (the installed CLI version, the model this spawn runs) + * come from the caller. Pure. */ +export function godResumeDecision( + facts: GodSessionFacts, + now: number, + current: { cliVersion: string | null; model: string | null }, + limits: { maxContext: number; cacheMs: number } = { maxContext: GOD_RESUME_MAX_CONTEXT, cacheMs: GOD_RESUME_CACHE_MS } +): { fresh: boolean; reasons: GodFreshReason[] } { + const reasons: GodFreshReason[] = []; + if (facts.contextTokens !== null && facts.contextTokens > limits.maxContext) reasons.push('context-over-limit'); + if (facts.lastRequestAt !== null && now - facts.lastRequestAt > limits.cacheMs) reasons.push('cache-expired'); + if (facts.cliVersion && current.cliVersion && facts.cliVersion !== current.cliVersion) reasons.push('cli-version-changed'); + const was = cacheModelKey(facts.model); + const will = cacheModelKey(current.model); + if (was && will && was !== will) reasons.push('model-changed'); + return { fresh: reasons.length > 0, reasons }; +} + +/** + * R1, one candidate session of an AUTOMATIC god resume: read its transcript, decide, log the + * decision (`god-startup-fresh` / `god-startup-resume`), and on a fresh start retire the session + * and arm the handoff. Returns the reasons (fresh) or null (resume as before). No transcript = + * null without a row: the resume-miss path decides that case, as before. + */ +export function godFreshStart( + sessionId: string, + deps: { + agentId: string; + current: { cliVersion: string | null; model: string | null }; + transcriptPath(sessionId: string): string | null; + now(): number; + log(row: Record): void; + retire(sessionId: string): void; + arm(h: { reasons: string[]; previousSession: string | null; contextTokens: number | null }): void; + readFacts?: (file: string) => GodSessionFacts; + } +): GodFreshReason[] | null { + const file = deps.transcriptPath(sessionId); + if (!file) return null; + const facts = (deps.readFacts ?? readGodSessionFacts)(file); + const now = deps.now(); + const d = godResumeDecision(facts, now, deps.current); + deps.log({ + kind: d.fresh ? 'god-startup-fresh' : 'god-startup-resume', agentId: deps.agentId, sessionId, reasons: d.reasons, + contextTokens: facts.contextTokens, ageMs: facts.lastRequestAt !== null ? now - facts.lastRequestAt : null, + cliVersion: { was: facts.cliVersion, now: deps.current.cliVersion }, model: { was: facts.model, now: deps.current.model } + }); + if (!d.fresh) return null; + deps.retire(sessionId); + deps.arm({ reasons: d.reasons, previousSession: sessionId, contextTokens: facts.contextTokens }); + return d.reasons; +} + +/** The installed Claude Code version (`x.y.z`) of the `claude` at `commandPath`, from its npm + * package.json (no process is started), or null (a native install, or unreadable). */ +export function readClaudeVersion(commandPath: string | null | undefined): string | null { + if (!commandPath) return null; + const PKG = join('@anthropic-ai', 'claude-code', 'package.json'); + const dir = dirname(commandPath); + const candidates = [join(dir, 'node_modules', PKG), join(dir, '..', 'lib', 'node_modules', PKG)]; + try { + const real = realpathSync(commandPath); + const marker = `${sep}@anthropic-ai${sep}claude-code${sep}`; + const i = real.indexOf(marker); + if (i >= 0) candidates.unshift(join(real.slice(0, i + marker.length), 'package.json')); + } catch { /* not resolvable: the candidates above */ } + for (const p of candidates) { + try { + if (!existsSync(p)) continue; + const pkg = JSON.parse(readFileSync(p, 'utf8')) as { name?: unknown; version?: unknown }; + if (pkg.name === '@anthropic-ai/claude-code' && typeof pkg.version === 'string' && /^\d+\.\d+\.\d+/.test(pkg.version)) return pkg.version; + } catch { /* the next */ } + } + return null; +} + +/** Bounds of the handoff pieces (characters). The whole block stays under GOD_HANDOFF_MAX, so it + * fits one hook's additionalContext (the joined budget is MAIL_JOINED_BUDGET, 9,500) with room + * to spare; a longer file is cut with a pointer to it. */ +export const GOD_HANDOFF_LESSONS_MAX = 3_000; +export const GOD_HANDOFF_FILE_MAX = 2_500; +export const GOD_HANDOFF_MAX = 9_000; + +/** The `## How I work (standing lessons)` section of a memory.md (up to the next `## `), or ''. */ +export function standingLessons(memory: string): string { + const lines = memory.split(/\r?\n/); + const start = lines.findIndex((l) => /^## How I work/.test(l)); + if (start < 0) return ''; + let end = lines.length; + for (let i = start + 1; i < lines.length; i += 1) if (/^## /.test(lines[i])) { end = i; break; } + return lines.slice(start, end).join('\n').trim(); +} + +const bounded = (s: string, max: number): string => (s.length > max ? `${s.slice(0, max)}\n[... cut at ${max} characters; read the file for the rest]` : s); + +/** + * R1: the handoff a FRESH god gets in context (its first SessionStart / UserPromptSubmit hook): + * why it started fresh, its standing lessons, the floor digest and the board status, each + * bounded. Pending mail is not copied here: it stays in the ledger and arrives through the + * normal injection (an open surfacing epoch of the old process was already put back + * to delivered by the restart recovery), and open obligations are listed there as before. + */ +export interface GodHandoffInput { + reasons: readonly string[]; + previousSession: string | null; + contextTokens: number | null; + memory: string | null; + floorDigest: string | null; + boardStatus: string | null; +} + +export function godHandoffContext(input: GodHandoffInput): string { + return godHandoffFit(input, GOD_HANDOFF_MAX).text; +} + +/** + * Creed B1: the handoff shares ONE hook additionalContext with the roster, goal and steer, and + * Claude Code shows only a 2,000-character preview of one over 10,000. So it is built to a + * `budget` (the caller: MAIL_JOINED_BUDGET minus the other joined parts). Over budget, the pieces + * are cut from the end: board-status first, then the floor digest, then the lessons, each with a + * pointer to its file; the header is cut last. `cut` names what was shortened. + */ +export function godHandoffFit(input: GodHandoffInput, budget: number): { text: string; cut: string[] } { + const why = input.reasons.join(', '); + const size = input.contextTokens !== null ? ` (its last request carried ${Math.round(input.contextTokens / 1000)}K tokens)` : ''; + const head = `\n\nYou started FRESH instead of resuming your previous session${input.previousSession ? ` ${input.previousSession}` : ''}${size}, because: ${why}. Resuming it would have re-sent the whole old conversation at every step. Everything you need to carry on is below or on disk: your memory.md, the board, and your hive mail, which arrives in context as usual.`; + const tail = '\n\n'; + const lessons = input.memory ? standingLessons(input.memory) : ''; + const pieces: Array<{ name: string; label: string; body: string }> = []; + if (lessons) pieces.push({ name: 'lessons', label: 'Your standing lessons (memory.md):', body: bounded(lessons, GOD_HANDOFF_LESSONS_MAX) }); + if (input.floorDigest?.trim()) pieces.push({ name: 'floor-digest', label: 'floor-digest.md:', body: bounded(input.floorDigest.trim(), GOD_HANDOFF_FILE_MAX) }); + if (input.boardStatus?.trim()) pieces.push({ name: 'board-status', label: 'board-status.md:', body: bounded(input.boardStatus.trim(), GOD_HANDOFF_FILE_MAX) }); + const max = Math.max(0, Math.min(budget, GOD_HANDOFF_MAX)); + const render = (): string => head + pieces.map((p) => `\n\n${p.label}\n${p.body}`).join('') + tail; + const cut: string[] = []; + const POINTER = '[cut to fit the hook; read the file]'; + for (let i = pieces.length - 1; i >= 0 && render().length > max; i -= 1) { + const over = render().length - max; + const p = pieces[i]; + const keep = Math.max(0, p.body.length - over - POINTER.length - 1); + p.body = keep > 0 ? `${p.body.slice(0, keep)}\n${POINTER}` : POINTER; + cut.push(p.name); + if (render().length > max && keep > 0) { p.body = POINTER; } + } + let text = render(); + if (text.length > max) { + // Even the pointers do not fit: keep the header (cut) and the closing tag. + cut.push('header'); + text = max > tail.length ? `${(head + pieces.map((p) => `\n\n${p.label} ${POINTER}`).join('')).slice(0, max - tail.length)}${tail}` : ''; + } + return { text, cut }; +} diff --git a/src/main/heavyJob.ts b/src/main/heavyJob.ts new file mode 100644 index 0000000000..b08e86bf22 --- /dev/null +++ b/src/main/heavyJob.ts @@ -0,0 +1,319 @@ +/** + * HEAVY-JOB-SERIALIZE (1.1.55; god andyheavy/andyheavycfg). On 2026-09-26 the Human's PC went + * unresponsive with several CPU/antivirus-heavy jobs running at once from different agents (an + * npm ci + electron-rebuild, a parity replay spawning a Python process per query, full test + * suites, mutation runs). The floor rule "one heavy job at a time" was prose only, and it was + * broken twice. This makes it a machine lock the app enforces at the PreToolUse boundary. + * + * - classifyHeavy: a pure, table-driven classifier of a tool call's command (no model). + * - HeavyJobLock: a counting semaphore of `limit` slots (the Settings value "Heavy jobs at once": + * 'off' or N, default 1), held per AGENT; released by the PostToolUse of a foreground call, by + * the job's processes exiting (a background job; checked only while held), by the holder's PTY + * exiting, or by a TTL. + * No Electron dependency: every piece is injectable, so the tests never inspect the real machine. + */ +import { execFile } from 'node:child_process'; + +export type HeavyKind = 'install' | 'build' | 'suite' | 'bench'; +export interface HeavyClass { heavy: boolean; kind?: HeavyKind; why?: string } + +/** Command-shaped tool input (the same reading as DESKTOP-LAUNCH-GUARD's). */ +export function commandFromToolInput(input: unknown): string | null { + if (typeof input === 'string') return input; + if (!input || typeof input !== 'object' || Array.isArray(input)) return null; + const r = input as Record; + const v = r.command ?? r.cmd ?? r.script; + if (Array.isArray(v) && v.every((x) => typeof x === 'string')) return (v as string[]).join(' '); + return typeof v === 'string' ? v : null; +} + +/** Split a command line into words, honouring simple quotes (not a full shell parser). */ +function words(s: string): string[] { + const out: string[] = []; + const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(s))) out.push(m[1] ?? m[2] ?? m[3]); + return out; +} + +/** The segments a shell would run: split on ; && || | and newlines, OUTSIDE quotes. */ +function segments(cmd: string): string[] { + const out: string[] = []; + let cur = ''; let q: string | null = null; + for (let i = 0; i < cmd.length; i++) { + const c = cmd[i]; + if (q) { cur += c; if (c === q && cmd[i - 1] !== '\\') q = null; continue; } + if (c === '"' || c === "'") { q = c; cur += c; continue; } + if (c === ';' || c === '\n' || c === '|' || c === '&') { + if (c === '&' && cmd[i + 1] !== '&' && cmd[i - 1] !== '&') { cur += ' &'; out.push(cur); cur = ''; continue; } // a lone & = background + out.push(cur); cur = ''; + if ((c === '&' || c === '|') && cmd[i + 1] === c) i++; + continue; + } + cur += c; + } + out.push(cur); + return out.map((s) => s.trim()).filter(Boolean); +} + +const WRAPPERS = new Set(['bash', 'sh', 'zsh', 'cmd', 'cmd.exe', 'powershell', 'powershell.exe', 'pwsh', 'pwsh.exe', 'bash.exe']); +const BENCH_SCRIPT = /(mutant|mutation|replay|bench|backfill|parity|speed|stress|soak)[^\\/]*\.(c?m?js|ts)$/i; +export const SUITE_MANY_FILES = 20; +/** node flags that take their value as the NEXT argument (so that value is not a test file). */ +const NODE_VALUE_FLAGS = new Set(['--test-name-pattern', '--test-skip-pattern', '--test-reporter', '--test-reporter-destination', '--test-concurrency', '--test-timeout', '--test-shard', '--import', '--require', '-r', '--loader', '--experimental-loader', '--env-file', '--conditions', '-C', '--input-type']); + +/** Strip the prefixes that do not change what runs: VAR=x, env [-u X]..., timeout N, nice, cd x. */ +function leading(ws: string[]): string[] { + let i = 0; + while (i < ws.length) { + const w = ws[i]; + if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) { i++; continue; } + if (w === 'env') { i++; while (i < ws.length && (ws[i].startsWith('-') || /^[A-Za-z_][A-Za-z0-9_]*=/.test(ws[i]))) { if (ws[i] === '-u') i++; i++; } continue; } + if (w === 'timeout' || w === 'nice') { i++; while (i < ws.length && (/^-/.test(ws[i]) || /^\d+[smhd]?$/.test(ws[i]))) i++; continue; } + if (w === 'cd' || w === 'pushd') return []; // `cd x` alone is its own segment + break; + } + return ws.slice(i); +} + +function classifyWords(ws0: string[], depth: number): HeavyClass { + const ws = leading(ws0); + if (!ws.length) return { heavy: false }; + const bin = ws[0].replace(/\\/g, '/').split('/').pop()!.toLowerCase().replace(/\.(exe|cmd)$/, ''); + const args = ws.slice(1); + // One level of a shell wrapper: bash -c "...", cmd /c ..., powershell -Command ... + if (WRAPPERS.has(bin) && depth === 0) { + // -c (sh), /c /k (cmd; Git Bash spells it //c), -Command (PowerShell) + const k = args.findIndex((a) => /^(-c|\/\/?c|\/\/?k|-command)$/i.test(a)); + if (k >= 0) return classifyCommand(args.slice(k + 1).join(' '), depth + 1); + return { heavy: false }; + } + const has = (...xs: string[]): boolean => xs.some((x) => args.includes(x)); + if (bin === 'npm' || bin === 'pnpm' || bin === 'yarn') { + const sub = args.find((a) => !a.startsWith('-')) ?? (bin === 'yarn' ? 'install' : ''); + if (sub === 'ci' || sub === 'rebuild') return { heavy: true, kind: 'install', why: `${bin} ${sub}` }; + if (sub === 'install' || sub === 'i' || sub === 'add') { + // `npm install` with no package = a full install; with packages it is still an install + return { heavy: true, kind: 'install', why: `${bin} ${sub}` }; + } + if (sub === 'test' || sub === 't') return { heavy: true, kind: 'suite', why: `${bin} test` }; + if (sub === 'run' || sub === 'run-script') { + const script = args.slice(args.indexOf(sub) + 1).find((a) => !a.startsWith('-')) ?? ''; + if (/^(build|dist)(:.*)?$/.test(script)) return { heavy: true, kind: 'build', why: `${bin} run ${script}` }; + if (/^test(:.*)?$/.test(script)) return { heavy: true, kind: 'suite', why: `${bin} run ${script}` }; + } + return { heavy: false }; + } + if (bin === 'npx') return classifyWords(args.filter((a) => !a.startsWith('-')), depth); + if (bin === 'electron-rebuild' || bin === 'node-gyp') return { heavy: true, kind: 'install', why: bin }; + if (bin === 'electron-builder') return { heavy: true, kind: 'build', why: bin }; + if (bin === 'electron-vite' && has('build')) return { heavy: true, kind: 'build', why: 'electron-vite build' }; + if (bin === 'vitest') { + const files = args.filter((a) => !a.startsWith('-') && a !== 'run'); + if (!files.length) return { heavy: true, kind: 'suite', why: 'vitest (all)' }; + return { heavy: false }; + } + if (bin === 'node' || bin === 'electron' || bin === 'munder difflin') { + if (args.some((a) => a.startsWith('--native-memory-bench'))) return { heavy: true, kind: 'bench', why: 'native-memory bench' }; + if (args.includes('--test')) { + // Positional args only: the value of a flag that takes one is not a test file. + const files: string[] = []; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a.startsWith('-')) { if (NODE_VALUE_FLAGS.has(a)) i++; continue; } + files.push(a); + } + if (!files.length) return { heavy: true, kind: 'suite', why: 'node --test (all)' }; + if (files.some((f) => /[*?]/.test(f) || !/\.[cm]?[jt]s$/.test(f))) return { heavy: true, kind: 'suite', why: 'node --test (a glob or a directory)' }; + if (files.length >= SUITE_MANY_FILES) return { heavy: true, kind: 'suite', why: `node --test (${files.length} files)` }; + return { heavy: false }; + } + const script = args.find((a) => !a.startsWith('-')); + if (script && BENCH_SCRIPT.test(script)) return { heavy: true, kind: 'bench', why: `node ${script.replace(/\\/g, '/').split('/').pop()}` }; + return { heavy: false }; + } + return { heavy: false }; +} + +/** Classify a command line: heavy if ANY segment it runs is heavy. */ +export function classifyCommand(cmd: string, depth = 0): HeavyClass { + for (const seg of segments(cmd)) { + const c = classifyWords(words(seg.replace(/\s&$/, '')), depth); + if (c.heavy) return c; + } + return { heavy: false }; +} + +/** Classify a tool call (any provider: the command-shaped input only). */ +export function classifyHeavy(_toolName: string | undefined, input: unknown): HeavyClass { + const cmd = commandFromToolInput(input); + return cmd ? classifyCommand(cmd) : { heavy: false }; +} + +/** Does this tool call leave the job running after the call returns? */ +export function isBackground(input: unknown): boolean { + if (input && typeof input === 'object' && (input as Record).run_in_background === true) return true; + const cmd = commandFromToolInput(input) ?? ''; + return /(^|[^&])&\s*$/.test(cmd.trim()) || /\bnohup\b|\bStart-Job\b|\bsetsid\b/.test(cmd); +} + +// — the lock — + +export type HeavyLimit = number | 'off'; +/** The Settings value, normalised: 'off', or an integer 1..16 (default 1). */ +export function heavyLimit(v: unknown): HeavyLimit { + if (v === 'off' || v === false || v === 0) return 'off'; + const n = typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : NaN; + return Number.isInteger(n) && n >= 1 ? Math.min(16, n) : 1; +} + +export interface HeavyHolder { + agentId: string; + kind: HeavyKind; + command: string; + since: number; + /** Last acquire or re-entry: the TTL runs from here. */ + touched: number; + /** Foreground heavy calls still running (their PostToolUse releases them). */ + calls: Set; + /** A backgrounded job: released by its processes exiting (the watcher), the PTY, or the TTL. */ + background: boolean; + /** Consecutive watcher scans that found no heavy process of this holder. */ + misses: number; +} + +export interface ProcRow { pid: number; parentPid: number; commandLine: string } +export interface HeavyLockDeps { + limit: () => HeavyLimit; + now?: () => number; + setTimer?: (fn: () => void, ms: number) => unknown; + clearTimer?: (t: unknown) => void; + /** The PTY root pid of each agent (main's pty manager). */ + roots?: () => Array<{ agentId: string; pid: number }>; + /** A process listing (hidden; only ever called while a background holder exists). */ + probe?: () => Promise; + log?: (row: Record) => void; +} + +export const HEAVY_TTL_MS = 60 * 60_000; +export const HEAVY_SCAN_MS = 20_000; +export const HEAVY_SCAN_MISSES = 2; + +export type HeavyDecision = { allow: true; acquired: boolean } | { allow: false; reason: string; holders: HeavyHolder[] }; + +export class HeavyJobLock { + private readonly holders = new Map(); + private timer: unknown = null; + private readonly now: () => number; + private readonly setTimer: (fn: () => void, ms: number) => unknown; + private readonly clearTimer: (t: unknown) => void; + + constructor(private readonly d: HeavyLockDeps) { + this.now = d.now ?? Date.now; + this.setTimer = d.setTimer ?? ((fn, ms) => { const t = setTimeout(fn, ms); (t as { unref?: () => void }).unref?.(); return t; }); + this.clearTimer = d.clearTimer ?? ((t) => clearTimeout(t as NodeJS.Timeout)); + } + + /** The current holders (fleet.json, the deny text, tests). Expired ones are released first. */ + snapshot(): Array<{ agentId: string; kind: HeavyKind; command: string; since: string; background: boolean }> { + this.expire(); + return [...this.holders.values()].map((h) => ({ agentId: h.agentId, kind: h.kind, command: h.command, since: new Date(h.since).toISOString(), background: h.background })); + } + + /** PreToolUse: a heavy call from `agentId`. Take a slot, share the agent's own, or deny. */ + acquire(agentId: string, cls: HeavyClass, command: string, callId: string, background: boolean): HeavyDecision { + const limit = this.d.limit(); + if (limit === 'off' || !cls.heavy || !cls.kind) return { allow: true, acquired: false }; + this.expire(); + const mine = this.holders.get(agentId); + if (mine) { + // Re-entrant: an agent's heavy calls share its one slot (and refresh its TTL). + mine.calls.add(callId); mine.background = mine.background || background; mine.touched = this.now(); mine.misses = 0; + this.log({ kind: 'heavy-lock', action: 'reenter', agentId, heavyKind: cls.kind, command: command.slice(0, 200) }); + this.arm(); + return { allow: true, acquired: false }; + } + if (this.holders.size >= limit) { + const holders = [...this.holders.values()]; + const who = holders.map((h) => `${h.agentId} (${h.kind}: ${h.command.slice(0, 80)}, since ${new Date(h.since).toISOString().slice(11, 19)}Z)`).join('; '); + const reason = `Denied by HEAVY-JOB-LOCK: the machine allows ${limit} heavy job${limit === 1 ? '' : 's'} at once and ${holders.length === 1 ? 'it is' : 'they are'} held by ${who}. Wait until a slot is released (when that job finishes, or after ${Math.round(HEAVY_TTL_MS / 60_000)} min), or ask the orchestrator. Light work (single test files, reads, edits) is not limited.`; + this.log({ kind: 'heavy-lock', action: 'deny', agentId, heavyKind: cls.kind, command: command.slice(0, 200), holders: holders.map((h) => h.agentId), limit }); + return { allow: false, reason, holders }; + } + this.holders.set(agentId, { agentId, kind: cls.kind, command: command.slice(0, 200), since: this.now(), touched: this.now(), calls: new Set([callId]), background, misses: 0 }); + this.log({ kind: 'heavy-lock', action: 'acquire', agentId, heavyKind: cls.kind, command: command.slice(0, 200), background, limit }); + this.arm(); + return { allow: true, acquired: true }; + } + + /** PostToolUse of a heavy call: a FOREGROUND call's job is done. The slot is freed when the + * agent has no foreground call left and no background job. */ + callDone(agentId: string, callId: string): void { + const h = this.holders.get(agentId); + if (!h || !h.calls.delete(callId)) return; + if (!h.calls.size && !h.background) this.release(agentId, 'posttool'); + } + + /** The holder's PTY exited: its jobs are gone with it. */ + agentGone(agentId: string): void { + if (this.holders.has(agentId)) this.release(agentId, 'pty-exit'); + } + + private release(agentId: string, reason: 'posttool' | 'process-exit' | 'pty-exit' | 'ttl'): void { + const h = this.holders.get(agentId); + if (!h) return; + this.holders.delete(agentId); + this.log({ kind: 'heavy-lock', action: 'release', agentId, heavyKind: h.kind, reason, heldMs: this.now() - h.since }); + if (!this.holders.size && this.timer) { this.clearTimer(this.timer); this.timer = null; } + } + + private expire(): void { + const t = this.now(); + for (const h of [...this.holders.values()]) if (t - h.touched >= HEAVY_TTL_MS) this.release(h.agentId, 'ttl'); + } + + /** The watcher runs only while a slot is held: TTL expiry for everyone, and a process check + * for background holders (a hidden listing every HEAVY_SCAN_MS). */ + private arm(): void { + if (this.timer || !this.holders.size) return; + this.timer = this.setTimer(() => { this.timer = null; void this.scan().finally(() => this.arm()); }, HEAVY_SCAN_MS); + } + + /** One watcher tick (exported for tests). */ + async scan(): Promise { + this.expire(); + const bg = [...this.holders.values()].filter((h) => h.background); + if (!bg.length || !this.d.probe || !this.d.roots) return; + let procs: ProcRow[]; + try { procs = await this.d.probe(); } catch { return; } + const parent = new Map(procs.map((p) => [p.pid, p.parentPid])); + const rootOf = new Map(this.d.roots().map((r) => [r.pid, r.agentId])); + const ownerOf = (pid: number): string | null => { + const seen = new Set(); let cur: number | undefined = pid; + while (cur !== undefined && !seen.has(cur)) { seen.add(cur); const a = rootOf.get(cur); if (a) return a; cur = parent.get(cur); } + return null; + }; + const busy = new Set(); + for (const p of procs) if (classifyCommand(p.commandLine).heavy) { const a = ownerOf(p.pid); if (a) busy.add(a); } + for (const h of bg) { + if (busy.has(h.agentId)) { h.misses = 0; continue; } + h.misses++; + if (h.misses >= HEAVY_SCAN_MISSES) { h.background = false; if (!h.calls.size) this.release(h.agentId, 'process-exit'); } + } + } + + private log(row: Record): void { try { this.d.log?.(row); } catch { /* best effort */ } } +} + +/** The default process listing: one hidden, non-interactive PowerShell CIM query (Windows only). */ +export function probeProcesses(): Promise { + if (process.platform !== 'win32') return Promise.resolve([]); + const script = 'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress'; + return new Promise((resolve) => execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { windowsHide: true, timeout: 10_000, maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => { + if (err || !stdout.trim()) return resolve([]); + try { + const rows = JSON.parse(stdout) as Array<{ ProcessId: number; ParentProcessId: number; CommandLine: string | null }>; + resolve((Array.isArray(rows) ? rows : [rows]).filter((r) => r && Number.isInteger(r.ProcessId)).map((r) => ({ pid: r.ProcessId, parentPid: r.ParentProcessId, commandLine: r.CommandLine ?? '' }))); + } catch { resolve([]); } + })); +} diff --git a/src/main/hero.ts b/src/main/hero.ts index ae2d819a50..fbd71b043e 100644 --- a/src/main/hero.ts +++ b/src/main/hero.ts @@ -13,7 +13,7 @@ import { getText } from './fetchText'; import { parseHeroPayload, DEFAULT_HERO, type HeroPayload } from '../shared/heroPayload'; const HERO_URL = - 'https://raw.githubusercontent.com/chaitanyagiri/munder-difflin/main/docs/hero.json'; + 'https://raw.githubusercontent.com/jdell64/agent-stage/main/docs/hero.json'; /** Plan copy and sponsors change on a human timescale. */ const TTL_MS = 6 * 60 * 60 * 1000; diff --git a/src/main/hiddenClaude.ts b/src/main/hiddenClaude.ts index 528735f38a..42be8a6c34 100644 --- a/src/main/hiddenClaude.ts +++ b/src/main/hiddenClaude.ts @@ -1,30 +1,58 @@ -import * as pty from 'node-pty'; -import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; -import path from 'node:path'; +import { spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from 'node:child_process'; +import { randomUUID as nodeRandomUUID } from 'node:crypto'; +import { existsSync } from 'node:fs'; import { resolveCommand, userShellPath } from './shellEnv'; import { expandTilde } from './fs'; -import { projectDir } from './transcript'; import { ensureKilled } from './procKill'; /** - * Shared helper: run a HIDDEN interactive claude session (ephemeral PTY) and - * return the assistant's final text response. + * Shared helper: run ONE hidden `claude --print` process and return its structured + * JSON result. * - * "Hidden" means: not added to the PtyManager, not emitted to the renderer, - * not visible in the agent list or OfficeFloor scene. Each call spawns its own - * session and kills it after capture — no /clear needed, no context bleed. + * "Hidden" means: not added to the PtyManager, not emitted to the renderer, not visible + * in the agent list or OfficeFloor scene. Each call owns its own process and session id, + * and kills it after capture — no /clear needed, no context bleed. * - * Uses an interactive PTY (not `claude -p`) so calls draw from the user's - * normal interactive plan quota, not the Agent SDK credit that moves to a - * separate claim-required pool from 2026-06-15. + * WHY PRINT MODE, AND WHAT IT COSTS (changed in 1.1.47). This used to run an interactive + * PTY and read the answer back out of the newest `.jsonl` under the shared Claude project + * directory. That protocol had no way to know which session it was reading: every hidden + * condensation for every agent shares one harness-home cwd, and the selector admitted any + * transcript touched within 5 s of spawn, so one agent's summary could be captured as + * another's. It also treated 3.5 s of TUI silence as turn completion, which it is not. + * The v1.1.46 log shows the result: 820 condense-abort records, zero successes. + * + * The old comment here justified the PTY as protecting the user's interactive-plan quota + * against Agent-SDK credit accounting. That premise no longer holds: per Anthropic's + * 2026-06-16 Agent SDK notice, the 2026-06-15 accounting change was PAUSED and `claude -p` + * still draws from subscription usage limits, with any future change to be announced + * first. Metering follows AUTHENTICATION, not print-vs-interactive. The real billing trap + * is credential precedence — `ANTHROPIC_API_KEY` overrides a logged-in subscription and + * bills pay-as-you-go — so this helper strips the credential-bearing variables from the + * child env (see API_KEY_ENV) and lets the user's subscription auth stand. Re-check that + * dated notice before a release that leans on it. * * Session lifecycle: - * spawn → boot-quiet detect → bracketed-paste prompt + \r → idle-settle → - * transcript JSONL extract (last assistant text block) → kill + * spawn (--print, one --session-id, --output-format json, --json-schema) → + * prompt written to stdin, stdin closed → bounded stdout/stderr → + * 'close' (streams drained, not mere 'exit') → envelope validated → cleanup */ -/** ms of PTY silence that signals the TUI is ready for input (boot complete). */ -const BOOT_QUIET_MS = 1500; +/** Hard cap on captured stdout. Far above a 1,500-word summary; stops a runaway + * child growing Electron main's heap. */ +const MAX_STDOUT_BYTES = 1024 * 1024; +/** Enough stderr for a diagnosis, never enough to be a log-sized payload. */ +const MAX_STDERR_BYTES = 8 * 1024; + +/** + * Credential-bearing variables that make the CLI bill pay-as-you-go even when the user is + * logged in to a subscription. Stripped from the child env AFTER every merge, so neither + * the inherited environment nor `opts.env` can reintroduce one. + * + * Deliberately NOT stripped: CLAUDE_CODE_USE_BEDROCK / CLAUDE_CODE_USE_VERTEX and the + * like. Those route a deliberately-configured deployment rather than silently overriding + * a subscription, and removing them would break a user who means to run there. + */ +export const API_KEY_ENV = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY_HELPER'] as const; export interface HiddenClaudeOptions { /** Model to use (e.g. 'claude-haiku-4-5'). */ @@ -37,10 +65,8 @@ export interface HiddenClaudeOptions { disallowedTools?: string[]; /** Directories added via --add-dir (for context gathering). */ addDirs?: string[]; - /** Hard cap ms before forcing prompt send regardless of boot activity. Default 7000. */ - bootCapMs?: number; - /** ms of PTY silence after the prompt that signals response is complete. Default 3500. */ - idleMs?: number; + /** JSON Schema the CLI must validate its structured output against. */ + jsonSchema?: unknown; /** Total timeout ms. Default 180000. */ timeoutMs?: number; /** Extra env merged over the resolved shell env (e.g. the shared MemPalace). */ @@ -49,54 +75,47 @@ export interface HiddenClaudeOptions { export interface HiddenClaudeResult { ok: boolean; - /** The assistant's final text response (stripped of any TUI framing). */ - text?: string; + /** The session id this attempt owns. Always present once a spawn was attempted. */ + sessionId?: string; + /** The CLI's `structured_output`, unvalidated beyond "it was present". */ + structuredOutput?: unknown; + /** The CLI's `result` string, kept for the caller's exact-JSON compatibility parse. */ + result?: string; + /** Stable category/detail. Never the prompt, the memory, or the full response. */ error?: string; } -/** - * Extract the last assistant text block from the transcript JSONL written - * at or after `spawnedAt`. Reuses projectDir() from transcript.ts. - */ -function extractLastAssistantText(cwd: string, spawnedAt: number): string | null { - try { - const dir = projectDir(cwd); - if (!existsSync(dir)) return null; - - const candidates: { f: string; mtime: number }[] = []; - for (const f of readdirSync(dir)) { - if (!f.endsWith('.jsonl')) continue; - try { - const mtime = statSync(path.join(dir, f)).mtimeMs; - // 5 s slack: include files that already existed at spawn but were - // updated by this session. Sort by mtime and take the newest. - if (mtime >= spawnedAt - 5000) candidates.push({ f, mtime }); - } catch { /* file removed between readdir and stat — skip */ } - } - if (!candidates.length) return null; - candidates.sort((a, b) => b.mtime - a.mtime); - - const lines = readFileSync(path.join(dir, candidates[0].f), 'utf8').split('\n'); - for (let i = lines.length - 1; i >= 0; i--) { - const trimmed = lines[i].trim(); - if (!trimmed) continue; - let rec: { type?: unknown; message?: { content?: unknown[] } }; - try { rec = JSON.parse(trimmed); } catch { continue; } - if (rec.type !== 'assistant') continue; - const content = rec.message?.content; - if (!Array.isArray(content)) continue; - for (let j = content.length - 1; j >= 0; j--) { - const block = content[j] as { type?: unknown; text?: unknown }; - if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) { - return block.text.trim(); - } - } - } - return null; - } catch { return null; } +/** The effects this module owns, injectable so the race is testable on a fake clock. */ +export interface HiddenClaudeDeps { + spawn: (file: string, args: string[], options: SpawnOptions) => ChildProcess; + randomUUID: () => string; + setTimeout: (fn: () => void, ms: number) => NodeJS.Timeout; + clearTimeout: (t: NodeJS.Timeout) => void; + ensureKilled: (pid: number | undefined) => void; } -export function runHiddenClaude(prompt: string, opts: HiddenClaudeOptions): Promise { +const defaultDeps: HiddenClaudeDeps = { + spawn: nodeSpawn as HiddenClaudeDeps['spawn'], + randomUUID: nodeRandomUUID, + setTimeout: (fn, ms) => setTimeout(fn, ms), + clearTimeout: (t) => clearTimeout(t), + ensureKilled +}; + +/** The Claude print-mode envelope, as much of it as we are willing to trust. */ +interface ClaudeEnvelope { + session_id?: unknown; + structured_output?: unknown; + result?: unknown; + is_error?: unknown; + subtype?: unknown; +} + +export function runHiddenClaude( + prompt: string, + opts: HiddenClaudeOptions, + deps: HiddenClaudeDeps = defaultDeps +): Promise { return new Promise((resolve) => { if (!prompt.trim()) { resolve({ ok: false, error: 'empty prompt' }); return; } // Defense-in-depth: `~` is shell syntax, not a path Node understands. @@ -105,111 +124,157 @@ export function runHiddenClaude(prompt: string, opts: HiddenClaudeOptions): Prom resolve({ ok: false, error: `cwd does not exist: ${opts.cwd}` }); return; } - opts = { ...opts, cwd }; + const sessionId = deps.randomUUID(); const binary = (opts.command || 'claude').trim().split(/\s+/)[0] || 'claude'; const exe = resolveCommand(binary); const disallowed = opts.disallowedTools ?? ['Edit', 'Write', 'NotebookEdit']; const addDirs = (opts.addDirs ?? []).filter((d) => d && existsSync(d)); + const timeoutMs = opts.timeoutMs ?? 180_000; const args: string[] = [ + '--print', '--model', opts.model, + '--session-id', sessionId, + '--output-format', 'json', '--permission-mode', 'bypassPermissions', '--disallowedTools', ...disallowed, ]; + if (opts.jsonSchema !== undefined) args.push('--json-schema', JSON.stringify(opts.jsonSchema)); for (const d of addDirs) { args.push('--add-dir', d); } - const bootCapMs = opts.bootCapMs ?? 7000; - const idleMs = opts.idleMs ?? 3500; - const timeoutMs = opts.timeoutMs ?? 180_000; + // The prompt NEVER goes on argv: memory input can exceed the Windows command-line + // limit, and argv is visible to every process on the machine. + const env: Record = { + ...process.env as Record, + PATH: userShellPath(), + ...(opts.env ?? {}), + }; + for (const k of API_KEY_ENV) delete env[k]; - const spawnedAt = Date.now(); - // Windows: node-pty's CreateProcess can't exec the npm `.cmd`/extensionless - // `claude` shim directly (ERROR_BAD_EXE_FORMAT, error 193) — route non-.exe - // targets through cmd.exe. A real claude.exe (WinGet) launches directly. (#22) + // Windows: CreateProcess cannot exec the npm `.cmd`/extensionless `claude` shim + // directly (ERROR_BAD_EXE_FORMAT, error 193), and Node refuses a .cmd without a + // shell — route non-.exe targets through cmd.exe. A real claude.exe (WinGet) + // launches directly. (#22) const winWrap = process.platform === 'win32' && !/\.(exe|com)$/i.test(exe); const spawnFile = winWrap ? (process.env.ComSpec || 'cmd.exe') : exe; - const spawnArgs = winWrap ? ['/c', exe, ...args] : args; - let ptyProc: pty.IPty; + const spawnArgs = winWrap ? ['/d', '/s', '/c', exe, ...args] : args; + + let child: ChildProcess; try { - ptyProc = pty.spawn(spawnFile, spawnArgs, { - name: 'xterm-color', - cols: 220, - rows: 50, - cwd: opts.cwd, - env: { - ...process.env, - PATH: userShellPath(), - ...(opts.env ?? {}), - } as Record, + child = deps.spawn(spawnFile, spawnArgs, { + cwd, + env, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], }); } catch (e) { - resolve({ ok: false, error: e instanceof Error ? e.message : String(e) }); + resolve({ ok: false, sessionId, error: e instanceof Error ? e.message : String(e) }); return; } let settled = false; - let promptSent = false; - let bootTimer: NodeJS.Timeout | null = null; - let idleTimer: NodeJS.Timeout | null = null; - let bootMaxTimer: NodeJS.Timeout; - let globalTimer: NodeJS.Timeout; - - // Hidden sessions are ephemeral CHECKS — nothing they spawn (MCP servers, - // helpers) may outlive them. Kill politely, then sweep the process group so - // every check releases its PIDs even if `claude` shrugs off the SIGHUP. - const kill = () => { - const pid = ptyProc.pid; - try { ptyProc.kill(); } catch { /* noop */ } - ensureKilled(pid); + let stdout = ''; + let stderr = ''; + let overflowed = false; + let timer: NodeJS.Timeout | null = null; + + // Hidden sessions are ephemeral CHECKS — nothing they spawn (MCP servers, helpers) + // may outlive them. Kill politely, then sweep the process tree so every check + // releases its PIDs even if `claude` shrugs off the signal. + const kill = (): void => { + const pid = child.pid; + try { child.kill(); } catch { /* already gone */ } + deps.ensureKilled(pid); }; - const finish = (r: HiddenClaudeResult) => { - if (settled) return; + const finish = (r: HiddenClaudeResult): void => { + if (settled) return; // a late 'close' after a timeout is ignored settled = true; - if (bootTimer) { clearTimeout(bootTimer); bootTimer = null; } - if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } - clearTimeout(bootMaxTimer); - clearTimeout(globalTimer); - kill(); - resolve(r); + if (timer) { deps.clearTimeout(timer); timer = null; } + resolve({ sessionId, ...r }); }; - const captureAndFinish = () => { - const text = extractLastAssistantText(opts.cwd, spawnedAt); - finish(text - ? { ok: true, text } - : { ok: false, error: 'no assistant response found in transcript' }); + const abort = (error: string): void => { + if (settled) return; + kill(); + finish({ ok: false, error }); }; - const sendPrompt = () => { - if (settled || promptSent) return; - promptSent = true; - if (bootTimer) { clearTimeout(bootTimer); bootTimer = null; } - // Bracketed paste + enter — same mechanism as submitToPty in useHive.ts. - ptyProc.write(`\x1b[200~${prompt}\x1b[201~`); - setTimeout(() => { if (!settled) ptyProc.write('\r'); }, 140); - }; + child.on('error', (e: Error) => abort(e.message)); + + child.stdout?.setEncoding?.('utf8'); + child.stdout?.on('data', (chunk: string | Buffer) => { + if (settled || overflowed) return; + stdout += typeof chunk === 'string' ? chunk : chunk.toString('utf8'); + if (stdout.length > MAX_STDOUT_BYTES) { + overflowed = true; + abort(`stdout exceeded ${MAX_STDOUT_BYTES} bytes`); + } + }); - bootMaxTimer = setTimeout(sendPrompt, bootCapMs); - globalTimer = setTimeout( - () => finish({ ok: false, error: 'hidden session timed out' }), - timeoutMs, - ); - - ptyProc.onData(() => { - if (!promptSent) { - // Boot phase: reset quiet timer; send prompt once output goes quiet. - if (bootTimer) clearTimeout(bootTimer); - bootTimer = setTimeout(sendPrompt, BOOT_QUIET_MS); - } else { - // Response phase: reset idle timer; capture when output settles. - if (idleTimer) clearTimeout(idleTimer); - idleTimer = setTimeout(captureAndFinish, idleMs); + child.stderr?.setEncoding?.('utf8'); + child.stderr?.on('data', (chunk: string | Buffer) => { + if (stderr.length >= MAX_STDERR_BYTES) return; + stderr += typeof chunk === 'string' ? chunk : chunk.toString('utf8'); + if (stderr.length > MAX_STDERR_BYTES) stderr = stderr.slice(0, MAX_STDERR_BYTES); + }); + + // 'close', never 'exit': exit fires when the process ends, close when its stdio has + // been drained. A summary whose final bytes arrive between the two is the whole bug + // this module is being rewritten for. + child.on('close', (code: number | null) => { + if (settled) return; + if (code !== 0) { + const tail = stderr.trim().split('\n').slice(-3).join(' | ').slice(0, 500); + finish({ ok: false, error: `claude exited ${code ?? 'null'}${tail ? `: ${tail}` : ''}` }); + return; } + finish(readEnvelope(stdout, sessionId)); }); - // Session exited cleanly before idle — try to capture the transcript anyway. - ptyProc.onExit(() => { if (!settled) captureAndFinish(); }); + timer = deps.setTimeout(() => abort('hidden session timed out'), timeoutMs); + + try { + child.stdin?.end(prompt); + } catch (e) { + abort(e instanceof Error ? e.message : String(e)); + } }); } + +/** + * Validate the print-mode envelope. STRICT by construction: the whole of stdout must be + * one JSON object. There is no brace scanning, no fence stripping and no substring + * rescue — a partial or noisy capture is a failure, because the failure mode being fixed + * is exactly "plausible text from somewhere else was accepted as this agent's summary". + */ +export function readEnvelope(stdout: string, sessionId: string): HiddenClaudeResult { + const raw = stdout.trim(); + if (!raw) return { ok: false, error: 'empty stdout' }; + let env: ClaudeEnvelope; + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ok: false, error: 'stdout was not a JSON object' }; + } + env = parsed as ClaudeEnvelope; + } catch { + return { ok: false, error: 'stdout was not JSON' }; + } + if (env.is_error === true) { + const sub = typeof env.subtype === 'string' ? `: ${env.subtype}` : ''; + return { ok: false, error: `claude reported an error${sub}` }; + } + // A returned id that is not ours means the envelope describes someone else's turn — + // the exact confusion the old transcript selector shipped. Absent is tolerated: the + // fixed argv still owns the session. + if (typeof env.session_id === 'string' && env.session_id !== sessionId) { + return { ok: false, error: 'session id mismatch' }; + } + return { + ok: true, + structuredOutput: env.structured_output, + result: typeof env.result === 'string' ? env.result : undefined + }; +} diff --git a/src/main/hive.ts b/src/main/hive.ts index 5f9c38bffc..c63e46ca14 100644 --- a/src/main/hive.ts +++ b/src/main/hive.ts @@ -13,7 +13,7 @@ * Human-in-the-loop is native to each agent's Claude Code session: permission * prompts surface in the agent's own terminal (and can be approved remotely via * `/remote-control`). The hive keeps no separate approval queue — a message aimed - * at "human" is routed to the god/orchestrator, the human's proxy on the floor. + * at "human" is routed to the orchestrator, the human's proxy on the floor. * - single-committer git with retry/backoff + stale-lock recovery * * Everything here runs in the Electron main process. @@ -21,11 +21,13 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, statSync, lstatSync, realpathSync, rmSync, appendFileSync, - symlinkSync, unlinkSync, copyFileSync, cpSync, chmodSync + symlinkSync, unlinkSync, copyFileSync, cpSync, chmodSync, + openSync, readSync, closeSync } from 'node:fs'; import { join, dirname, basename, isAbsolute, relative } from 'node:path'; import { homedir } from 'node:os'; import { spawnSync, spawn, type ChildProcess } from 'node:child_process'; +import { AppendFile, LOG_KEEP_ROTATED, rotatedFiles } from './appendLog'; import { randomBytes, createHash } from 'node:crypto'; import type { AgentUsageSample } from './usage'; import { COMMAND_GROUPS } from '../shared/claudeCommands'; @@ -42,7 +44,9 @@ import { selectBroadcastTargets } from '../shared/broadcast'; import { preferredAgentRole } from '../shared/agentRole'; import { mergeTaskLedger } from '../shared/taskLedger'; import { expandTilde } from './fs'; -import { resolveGodName } from '../shared/godIdentity'; +import { resolveOrchestratorName, isOrchestratorAddress } from '../shared/orchestratorIdentity'; +import { legacyOrchestratorIdIn, migrateRegistry, ORCHESTRATOR_ID } from '../shared/orchestratorMigration'; +import { godHandoffFit } from './godStartup'; /** The subset of HarnessConfig the hive consumes for the default-MCP merge. * Kept as a local shape so hive.ts never imports the foundation-owned config @@ -71,7 +75,7 @@ export interface HiveMessage { conversation: string; in_reply_to: string | null; from: string; - to: string; // an agentId, 'god', or 'broadcast' + to: string; // an agentId, 'orchestrator', or 'broadcast' act: MessageAct; subject: string; body: string; @@ -125,7 +129,7 @@ export interface HiveTask { dependsOn: string[]; priority: number; createdAt: string; - /** First-class human feedback: the god appends {q} when a card can only + /** First-class human feedback: the orchestrator appends {q} when a card can only * proceed with the human's input (status goes blocked); the harness UI * fills in {a}. The full history stays on the card forever. */ humanQA?: HumanQA[]; @@ -151,8 +155,8 @@ export interface AgentMeta { role?: string; capabilities?: string[]; cwd: string; - isGod?: boolean; - /** Michael's prep assistant — enriches prompts and forwards them to Michael. + isOrchestrator?: boolean; + /** the orchestrator prep assistant — enriches prompts and forwards them to the orchestrator. * Send-only: excluded from broadcast fan-out so it never drains an inbox. */ isAssistant?: boolean; } @@ -164,7 +168,7 @@ export interface RegistryAgent extends AgentMeta { * (not deleted) so its history/memory survive; only agents with a live PTY * are 'active'. Broadcast fan-out + roster reads skip archived agents. */ archived?: boolean; - /** The human has this agent 1:1 and Michael must leave it alone until they + /** The human has this agent 1:1 and the orchestrator must leave it alone until they * flip it back. Held agents stay ACTIVE and keep their terminal — this is * "do not dispatch to them", not "they are gone", which is why it is its own * flag rather than a reuse of `archived` or a breaker level. */ @@ -174,6 +178,10 @@ export interface RegistryAgent extends AgentMeta { * resume after a crash/restart) AND the cost accounting/dedup key on every * AgentUsageSample / cost-ledger row. */ sessionId?: string; + /** Most recent Claude model id reported by this agent's status line. This is + * per-agent because Claude Code's global settings file cannot preserve + * independent `/model` choices across a hive. */ + model?: string; /** Whether `cwd` is actually usable for a (re)spawn — i.e. an ABSOLUTE path * that exists as a directory. Computed + persisted at spawn so the roster * reliably exposes each worker's environment validity. A non-absolute fragment @@ -183,7 +191,7 @@ export interface RegistryAgent extends AgentMeta { } export interface Registry { - godId: string | null; + orchestratorId: string | null; agents: Record; } @@ -384,6 +392,10 @@ function pruneCrashLogs(dir: string): void { } catch { /* unreadable directory is not worth failing an exit path over */ } } +/** First window logTail() reads off the end of a log file. Sized so the default 200 rows + * (~170 B each) land in one read; it quadruples from here if a caller asks for more. */ +const LOG_TAIL_WINDOW_BYTES = 256 * 1024; + export class HiveManager { /** * @param getHome Lazily resolve harnessHome so the hive follows config changes. @@ -396,6 +408,29 @@ export class HiveManager { private emit?: (channel: string, payload: unknown) => boolean | void ) {} + /** LOG-STALL-AV: the kept-open, rotated append files, per hive root (see appendLog.ts). */ + private readonly appendFiles = new Map(); + private keepAppendOpen = false; + private appendFileFor(path: string, keep: number): AppendFile { + let f = this.appendFiles.get(path); + if (!f) { f = new AppendFile(path, { keep, keepOpen: this.keepAppendOpen }); this.appendFiles.set(path, f); } + return f; + } + /** The APP keeps the log and ledger descriptors open (no antivirus rescan per row). Off by + * default so a caller that deletes the hive folder (tests) is never blocked by a held file. */ + keepAppendFilesOpen(on: boolean): void { + if (on === this.keepAppendOpen) return; + this.closeAppendFiles(); + this.keepAppendOpen = on; + } + /** Close the kept-open log and ledger descriptors. Rows are already on disk; the next row + * reopens. Call before deleting or copying the hive folder (reset, change home) and at + * quit: Windows cannot remove a directory while a file in it is open. */ + closeAppendFiles(): void { + for (const f of this.appendFiles.values()) f.close(); + this.appendFiles.clear(); + } + private routerTimer: NodeJS.Timeout | null = null; /** The embedded OTLP collector's loopback URL, set by the main process once the @@ -431,7 +466,7 @@ export class HiveManager { } /** Whether config.orchestratorMaySpawn is on, mirrored here so the prompt - * builder can decide whether to tell god the spawn queue is available. Set at + * builder can decide whether to tell orchestrator the spawn queue is available. Set at * bootstrap and on every config write; hive.ts deliberately does not import * the config module. */ private _maySpawn = false; @@ -615,11 +650,34 @@ export class HiveManager { // — bootstrap — + /** One-time upgrade of a hive written before the orchestrator rename: back up + * registry.json, move agents// to agents/orchestrator/, then rewrite the + * registry with the new keys. The dir move goes first and aborts everything on + * failure, so a half-migrated hive (registry says one thing, disk another) + * cannot happen. Idempotent: a migrated registry is left alone. */ + private migrateLegacyOrchestrator(root: string): void { + const regPath = join(root, 'registry.json'); + if (!existsSync(regPath)) return; + try { + const raw = JSON.parse(readFileSync(regPath, 'utf8')); + const legacyId = legacyOrchestratorIdIn(raw); + if (!legacyId) return; + copyFileSync(regPath, regPath + '.pre-orchestrator-rename'); + const oldDir = join(root, 'agents', legacyId); + const newDir = join(root, 'agents', ORCHESTRATOR_ID); + if (existsSync(oldDir) && !existsSync(newDir)) renameSync(oldDir, newDir); + this.atomicWriteJson(regPath, migrateRegistry(raw)); + } catch (e) { + console.error('[hive] orchestrator-rename migration skipped:', e instanceof Error ? e.message : e); + } + } + /** Create the hive skeleton + git repo if missing. Idempotent. */ ensureHive(): void { const root = this.root(); if (!root) return; mkdirSync(join(root, 'agents'), { recursive: true }); + this.migrateLegacyOrchestrator(root); for (const { filename, contents } of GENERATED_HIVE_DOCS) { const path = join(root, filename); @@ -628,7 +686,7 @@ export class HiveManager { const registry = join(root, 'registry.json'); if (!existsSync(registry)) { - this.writeJson(registry, { godId: null, agents: {} } as Registry); + this.writeJson(registry, { orchestratorId: null, agents: {} } as Registry); } const userCodexHome = join(homedir(), '.codex'); for (const [id, agent] of Object.entries(this.registry().agents)) { @@ -639,7 +697,7 @@ export class HiveManager { } const board = join(root, 'board.md'); if (!existsSync(board)) { - writeFileSync(board, '# Hive board\n\n_Shared plans live here. The god agent is the scribe._\n', 'utf8'); + writeFileSync(board, '# Hive board\n\n_Shared plans live here. The orchestrator is the scribe._\n', 'utf8'); } const tasks = join(root, 'tasks.json'); if (!existsSync(tasks)) this.writeJson(tasks, { tasks: [] }); @@ -653,7 +711,7 @@ export class HiveManager { // can include tokens, paths and prompt fragments, and the hive repo is // committed on every change — a secret written there would be permanent. // log.jsonl gets the structured, non-sensitive fields; the dump stays local. - const want = ['fleet.json', 'hooks.sock', 'cost-ledger.jsonl', 'crashes/', '.DS_Store']; + const want = ['fleet.json', 'usage-availability.json', 'hooks.sock', 'cost-ledger.jsonl', 'crashes/', '.DS_Store']; let lines: string[] = []; if (existsSync(gitignore)) { try { lines = readFileSync(gitignore, 'utf8').split('\n'); } catch { lines = []; } } const missing = want.filter((w) => !lines.includes(w)); @@ -749,7 +807,7 @@ export class HiveManager { const reg = this.registry(); const prev = reg.agents[meta.id]; if (meta.cwd) meta = { ...meta, cwd: expandTilde(meta.cwd) }; - const role = preferredAgentRole(meta.role, prev?.role, !!meta.isGod); + const role = preferredAgentRole(meta.role, prev?.role, !!meta.isOrchestrator); meta = { ...meta, role }; const identity = join(dir, 'identity.md'); @@ -790,10 +848,10 @@ export class HiveManager { archived: false, lastSeen: Date.now() }; - if (meta.isGod) reg.godId = meta.id; + if (meta.isOrchestrator) reg.orchestratorId = meta.id; this.atomicWriteJson(join(root, 'registry.json'), reg); - this.appendLog({ kind: 'spawn', agentId: meta.id, name: meta.name, isGod: !!meta.isGod }); + this.appendLog({ kind: 'spawn', agentId: meta.id, name: meta.name, isOrchestrator: !!meta.isOrchestrator }); // Only logs on an invalid cwd (rare) — not a per-spawn line, so no log spam. if (!cwd.valid) { this.appendLog({ kind: 'cwd_invalid', agentId: meta.id, cwd: meta.cwd, issue: cwd.issue }); @@ -882,7 +940,7 @@ export class HiveManager { // Auto mode keeps codex's OS sandbox (`-a never -s workspace-write`, // agentProvider.ts). workspace-write only covers cwd, so the agent // folder (inbox/.done, memory.md, outbox) and the shared hive root - // (research deliverables, the board for god) are added as extra + // (research deliverables, the board for orchestrator) are added as extra // writable roots. Harmless outside auto mode. for (const d of this.sandboxWritableDirs(meta, dir, root, opts.extraWritableDirs)) preArgs.push('--add-dir', d); } @@ -899,7 +957,7 @@ export class HiveManager { } else if (desc.shim === 'opencode') { // OpenCode (anomalyco/opencode) has no Claude-shaped Stop hook, but its - // plugin API exposes a real session.idle event (god Decision 1). We drop + // plugin API exposes a real session.idle event (orchestrator Decision 1). We drop // a bundled plugin into a PER-AGENT OPENCODE config dir that posts // HIVE_SOCK payloads on tool.execute.before/after + session.idle — the // same Stop→drain semantics, provider-agnostic, no traffic interception. @@ -1064,7 +1122,7 @@ export class HiveManager { * * Only mail that was already there when the worker signaled done is finished * with: `before` is that signal's timestamp, and a message created after it - * (a follow-up question from god that crossed the worker's done) is left in + * (a follow-up question from orchestrator that crossed the worker's done) is left in * place, still pending, for whoever picks the mailbox up next. Without the * cut-off such a message was filed as read and nobody ever saw it. Messages * whose `created_at` is unreadable fall back to the file's mtime. @@ -1117,14 +1175,14 @@ export class HiveManager { * The registry key, agent directory, session id, and every mailbox path remain * keyed by `id`; only the human-facing name is updated. * - * `fleet.json` is patched in the same operation so god's next prompt receives + * `fleet.json` is patched in the same operation so orchestrator's next prompt receives * the new name immediately rather than waiting for the periodic fleet refresh. */ /** - * Put an agent on hold, or take it off, and tell Michael immediately. + * Put an agent on hold, or take it off, and tell the orchestrator immediately. * * `fleet.json` is patched in the same operation for the same reason - * `renameAgent` does it: god's roster is injected from that file on its next + * `renameAgent` does it: orchestrator's roster is injected from that file on its next * prompt, and waiting up to 8s for the periodic refresh means one more * dispatch can still land on someone the human has just claimed. */ @@ -1220,12 +1278,63 @@ export class HiveManager { } catch { /* best-effort — never crash a hook handler */ } } + /** + * Persist only a Claude `/model` divergence from the current app default. + * Returning to the default removes the pin, so Settings model changes continue + * to reach agents that have not deliberately selected another model. + */ + recordModel(agentId: string, model: string, appDefault: string | undefined): void { + const root = this.root(); + const next = model.trim(); + if (!root || !next) return; + try { + const reg = this.registry(); + const agent = reg.agents[agentId]; + // Status payload shapes are shared by provider bridges. Only a real Claude + // agent may turn one into a Claude CLI argument on a later respawn. + if (!agent || agent.provider !== 'claude') return; + // `[1m]` selects a different context window and must remain a real pin. + // Only harmless whitespace/case differences are equivalent to the default. + const key = next.toLowerCase(); + const defaultKey = appDefault?.trim().toLowerCase() ?? ''; + if (defaultKey && key === defaultKey) { + if (!agent.model) return; + delete agent.model; + agent.lastSeen = Date.now(); + this.atomicWriteJson(join(root, 'registry.json'), reg); + this.appendLog({ kind: 'model', agentId, model: null }); + this.commit(`hive: model default ${agentId}`); + return; + } + if (agent.model?.trim().toLowerCase() === key) return; + agent.model = next; + agent.lastSeen = Date.now(); + this.atomicWriteJson(join(root, 'registry.json'), reg); + this.appendLog({ kind: 'model', agentId, model: next }); + this.commit(`hive: model ${agentId}`); + } catch { /* best-effort — never crash a status line */ } + } + /** The last known session_id for an agent, or undefined. Used to build a * `claude --resume ` spawn so a restarted agent resumes its thread. */ lastSession(agentId: string): string | undefined { return this.registry().agents[agentId]?.sessionId; } + /** The per-agent Claude model last reported by its status line. */ + lastModel(agentId: string): string | undefined { + return this.registry().agents[agentId]?.model; + } + + private retiredSessions = new Map>(); + /** A retired session id will not be resumed automatically. */ + retireSession(agentId: string, sessionId: string): void { + if (!sessionId) return; + const set = this.retiredSessions.get(agentId) ?? new Set(); + set.add(sessionId); + this.retiredSessions.set(agentId, set); + } + /** Claude Code settings that route every relevant hook through the shim, plus * (W3) the default MCP bundle merged into this PER-SESSION settings file. cwd * scopes the filesystem/git servers; cfg (the consent map) gates which servers @@ -1233,7 +1342,7 @@ export class HiveManager { /** * Directories a sandboxed agent may write BESIDES its cwd: its own agent * folder (hive housekeeping) and the hive root (research deliverables; the - * board and tasks.json for god; outbox delivery is done by main, not the agent). + * board and tasks.json for orchestrator; outbox delivery is done by main, not the agent). * This is what lets auto mode keep the OS sandbox on — the old full-bypass * posture existed only because these paths sit outside the project cwd. */ @@ -1483,7 +1592,7 @@ export class HiveManager { `You have ${fresh.length} new hive message(s) in your inbox. Address them before finishing:`, lines, // Native separators (join, not string-concatenated `/`) so a Windows agent is - // handed a path its own shell/tools accept, not `C:\…\agents\god/inbox/`. + // handed a path its own shell/tools accept, not `C:\…\agents\orchestrator/inbox/`. `Open the files in ${join(dir, 'inbox')} for full detail, act on each, then move handled ones to ${join(dir, 'inbox', '.done')}. Reply via your outbox if a message requires it.` ].join('\n'); return { block: true, reason }; @@ -1496,11 +1605,11 @@ export class HiveManager { return [ `# ${meta.name} (${meta.id})`, '', - `- Role: ${meta.role ?? (meta.isGod ? 'orchestrator (god)' : 'agent')}`, + `- Role: ${meta.role ?? (meta.isOrchestrator ? 'orchestrator' : 'agent')}`, `- Capabilities: ${caps}`, `- Working directory: ${meta.cwd}`, - meta.isGod ? '- You are the **god / orchestrator**. You run the floor — keep awareness of the whole team, delegate execution, and personally own only the important calls (decomposition, sign-offs, conflicts, integration), not the grunt work.' : '', - meta.isGod ? '- Monitor the team with `fleet.json` (live per-agent status/tokens/cost/breaker) and `registry.json`; full command reference in `COMMANDS.md`. `claude agents` does NOT list your hive siblings.' : '', + meta.isOrchestrator ? '- You are the **orchestrator**. You run the floor — keep awareness of the whole team, delegate execution, and personally own only the important calls (decomposition, sign-offs, conflicts, integration), not the grunt work.' : '', + meta.isOrchestrator ? '- Monitor the team with `fleet.json` (live per-agent status/tokens/cost/breaker) and `registry.json`; full command reference in `COMMANDS.md`. `claude agents` does NOT list your hive siblings.' : '', '' ].filter(Boolean).join('\n'); } @@ -1524,7 +1633,7 @@ export class HiveManager { * dead on every Windows floor. Bake the ABSOLUTE resolved path instead: it is * platform-independent, needs no expansion, and stays prompt-cache-stable. * - `'…' + '/inbox/'` — string-concatenating separators told a Windows agent to - * read `C:\Users\x\hive\agents\god/inbox/`. Use join() so the agent's own + * read `C:\Users\x\hive\agents\orchestrator/inbox/`. Use join() so the agent's own * tooling gets a path it can pass straight to its shell. */ private injectedPrompt( @@ -1540,11 +1649,11 @@ export class HiveManager { const inRoot = (...parts: string[]): string => join(root, ...parts); // Resolved ONCE here, at THIS agent's own spawn — same prompt-cache-stable // shape as name/id/dir/root above it, not a live re-read on every turn. - // Needed only for the PREP ASSISTANT persona below, which refers to god by - // name in prose; god's own prompt already gets its name via `meta.name`. - const godRegistry = meta.isAssistant ? this.registry() : null; - const godNameForPrompt = godRegistry - ? resolveGodName(godRegistry.agents[godRegistry.godId ?? 'god']?.name) + // Needed only for the PREP ASSISTANT persona below, which refers to orchestrator by + // name in prose; orchestrator's own prompt already gets its name via `meta.name`. + const orchestratorRegistry = meta.isAssistant ? this.registry() : null; + const orchestratorNameForPrompt = orchestratorRegistry + ? resolveOrchestratorName(orchestratorRegistry.agents[orchestratorRegistry.orchestratorId ?? 'orchestrator']?.name) : ''; const ctxLine = 'LIVE CONTEXT: each agent row in the LIVE ROSTER carries a `ctx NN%` tag — its live context-window occupancy. Treat it as the real headroom signal when routing: prefer an agent with a LOW `ctx` for a big task; treat a HIGH `ctx` (near 100%) as busy rather than idle, even if the cumulative token count looks modest.'; @@ -1569,29 +1678,29 @@ export class HiveManager { // us) was invisible to every investigation. const rt = this.runtimeInfo(); const runtimeLine = rt - ? `RUNNING BUILD: Munder Difflin v${rt.version}, ${rt.packaged ? 'packaged app' : 'local dev build'}${rt.appPath ? `, from ${rt.appPath}` : ''}. Say this version if asked which one is running, and do not assume behaviour from an older one. A local dev build inherits the launching shell's environment (umask included) where a packaged app does not, so file modes and inherited env can legitimately differ between the two. \`log.jsonl\` records an \`app-start\` event on every launch, which is how you spot a restart or a build switch.` + ? `RUNNING BUILD: Agent Stage v${rt.version}, ${rt.packaged ? 'packaged app' : 'local dev build'}${rt.appPath ? `, from ${rt.appPath}` : ''}. Say this version if asked which one is running, and do not assume behaviour from an older one. A local dev build inherits the launching shell's environment (umask included) where a packaged app does not, so file modes and inherited env can legitimately differ between the two. \`log.jsonl\` records an \`app-start\` event on every launch, which is how you spot a restart or a build switch.` : ''; - // Item 11: god could not find the spawn queue. The mechanism has worked since + // Item 11: orchestrator could not find the spawn queue. The mechanism has worked since // v0.4.4, but nothing told him it existed — the prompt said "spawn" without // saying how, COMMANDS.md and PROTOCOL.md did not mention it, and the only // description lived in a source comment. So he fell back to writing a hire // manifest, which needs a human to click confirm, and it looked like nothing // happened. Gated on the toggle: advertising a disabled path is worse than // saying nothing, and COMMANDS.md documents it either way for the case where - // the operator turns it on after god was already running. - const spawnQueueLine = meta.isGod && this.orchestratorMaySpawn() + // the operator turns it on after orchestrator was already running. + const spawnQueueLine = meta.isOrchestrator && this.orchestratorMaySpawn() ? `SPAWNING A WORKER: you can start an ephemeral worker yourself by writing ONE JSON file into ${inRoot('spawn-requests')}/.json. Required: \`objective\` (what the worker must do) and \`cwd\` (the repo it runs in). Optional: \`name\`, \`command\` (overrides the provider default), \`provider\` (selects its default CLI when command is omitted), \`model\`, \`isolate\` (default true = its own git worktree), \`tokenCap\`, and \`slack\` ({channel, thread_ts}) to route its failures back to a thread. The harness polls that directory, spawns \`worker-\`, and moves the request to \`spawn-requests/.done/\` on success or \`.failed/\` with a reason. This is the ONLY way you can spawn; a hire manifest under research/hires/ needs the human to confirm it in the UI, so it is not a route you can complete on your own. Reuse an existing agent first, as above — a worker is a fresh spend every time.` : ''; - 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.` + const orchestratorLine = meta.isOrchestrator + ? 'You are the 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. If ${inRoot('usage-availability.json')} exists, it holds each engine's remaining allowance and reset times (refreshed at every standup): route new work away from engines that are nearly out, and favor an engine whose window is about to reset with plenty unused.` : 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".'; - const guardrailsLine = 'Guardrails: a circuit breaker watches the floor — a "Circuit breaker: steer/constrain" message means you are looping or overspending, so STOP repeating, summarize what you tried, and follow it. Be token-frugal (a floor-wide or per-agent token budget can pause you). The shared plan has two parts: board.md (freeform; god is the sole scribe) and tasks.json (structured kanban — todo/doing/blocked/done).'; - const slackLine = meta.isGod + ? `You are ${orchestratorNameForPrompt}'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 ${orchestratorNameForPrompt}'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 ${orchestratorNameForPrompt} can execute autonomously, preserving the user's original intent without inventing scope. Then deliver it: write ONE message JSON into your outbox with "to":"orchestrator", "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 ${orchestratorNameForPrompt}.` + : 'For anything ambiguous, cross-cutting, or needing sign-off, address a message to "orchestrator".'; + const guardrailsLine = 'Guardrails: a circuit breaker watches the floor — a "Circuit breaker: steer/constrain" message means you are looping or overspending, so STOP repeating, summarize what you tried, and follow it. Be token-frugal (a floor-wide or per-agent token budget can pause you). The shared plan has two parts: board.md (freeform; orchestrator is the sole scribe) and tasks.json (structured kanban — todo/doing/blocked/done).'; + const slackLine = meta.isOrchestrator ? 'SLACK REPLIES: When composing a Slack reply (or writing the `result` field of a Slack-origin kanban card), you MUST: (1) directly address what the user asked — never a bare "done"; (2) include the relevant specifics, outcome, and details; (3) format for Slack mrkdwn — open with a short *bold* headline, use bullet points for multiple items, wrap code/paths in `backtick` blocks, keep it concise (no walls of text). When finishing a Slack-origin task, always write a complete, user-facing, well-formatted `result` on the kanban card — the system posts it verbatim to Slack as the done reply.' - : `SLACK REPLIES: If god dispatches you a task that came from Slack, it will include an exact \`"${hiveNode}" "" --channel … --thread … --text "…"\` reply command — when you finish, run it VERBATIM to post your result back to that thread yourself. The reply must be SUBSTANTIVE Slack mrkdwn (a short *bold* headline + the actual outcome/specifics/links), NEVER a bare "done".`; + : `SLACK REPLIES: If orchestrator dispatches you a task that came from Slack, it will include an exact \`"${hiveNode}" "" --channel … --thread … --text "…"\` reply command — when you finish, run it VERBATIM to post your result back to that thread yourself. The reply must be SUBSTANTIVE Slack mrkdwn (a short *bold* headline + the actual outcome/specifics/links), NEVER a bare "done".`; return [ `You are "${meta.name}" (${meta.id}), an autonomous agent in a collaborating hive of Claude agents.`, `Your private workspace is ${dir}. The shared hive is ${root}. Full protocol: ${inRoot('PROTOCOL.md')}.`, @@ -1604,7 +1713,7 @@ export class HiveManager { guardrailsLine, memoryLine, knowledgeLine, - godLine, + orchestratorLine, spawnQueueLine, runtimeLine, slackLine, @@ -1623,7 +1732,7 @@ export class HiveManager { conversation: partial.conversation ?? `conv-${shortRand()}`, in_reply_to: partial.in_reply_to ?? null, from: partial.from ?? from, - to: partial.to ?? 'god', + to: partial.to ?? 'orchestrator', act, subject: partial.subject ?? '', body: partial.body ?? '', @@ -1632,7 +1741,7 @@ export class HiveManager { // count, never authoritative. Clamp it into range: an echoed relay must // keep climbing toward the cap, while a copied-forward `hops: 13` must // not read as a runaway loop (only a harness bounce moves the counter — - // see bounceToGod). A negative value would just buy more free bounces, + // see bounceToOrchestrator). A negative value would just buy more free bounces, // so the floor is 0. hops: Math.max(0, Math.min(typeof partial.hops === 'number' ? partial.hops : 0, HOP_CAP)), requires_reply: partial.requires_reply ?? ['request', 'query', 'propose'].includes(act), @@ -1651,20 +1760,20 @@ export class HiveManager { return true; } - /** One harness bounce of `msg` to god: bump the hop counter, rewrite the + /** One harness bounce of `msg` to orchestrator: bump the hop counter, rewrite the * subject, and log the drop once the counter passes HOP_CAP. Agents can't * move hops past the cap themselves (normalize clamps what they wrote), so * this fuse can only fire on a REAL relay loop — a bounced mail that keeps * coming back undeliverable — and never on an agent-authored number. */ - private bounceToGod(msg: HiveMessage, godId: string, subject: string): void { + private bounceToOrchestrator(msg: HiveMessage, orchestratorId: string, subject: string): void { const hops = msg.hops + 1; if (hops > HOP_CAP) { // loop guard — drop a runaway message rather than let agents ping-pong. - // There's no human queue to fall back on; the god agent owns conflicts. + // There's no human queue to fall back on; the orchestrator owns conflicts. this.appendLog({ kind: 'drop', reason: 'hop-cap', from: msg.from, to: msg.to, id: msg.id }); return; } - this.deliver({ ...msg, hops, to: godId, subject }, godId); + this.deliver({ ...msg, hops, to: orchestratorId, subject }, orchestratorId); } /** Inject a message directly (used by the orchestrator / UI / tests). */ @@ -1677,11 +1786,11 @@ export class HiveManager { private routeMessage(msg: HiveMessage): void { const reg = this.registry(); - const godId = reg.godId ?? 'god'; + const orchestratorId = reg.orchestratorId ?? 'orchestrator'; // The hive has no separate human-approval queue — approvals are native to // each agent's Claude Code session (and approvable remotely). A message aimed - // at "human" is handled by the god/orchestrator, the human's proxy here. - const resolveTo = (to: string): string => (to === 'human' || to === 'god' ? godId : to); + // at "human" is handled by the orchestrator, the human's proxy here. + const resolveTo = (to: string): string => (isOrchestratorAddress(to, orchestratorId) ? orchestratorId : to); const targets = msg.to === 'broadcast' // The roster for fan-out is the ACTIVE registry: skip the send-only prep // assistant and any archived agent (closed tab). Hookless providers are @@ -1689,7 +1798,7 @@ export class HiveManager { // work order, so excluding them here only made a broadcast invisible to an // agent that direct mail reaches fine. See selectBroadcastTargets. ? selectBroadcastTargets(reg.agents, msg.from) - // Never deliver to self — guards a god → "human" message looping back to god. + // Never deliver to self — guards a orchestrator → "human" message looping back to orchestrator. : [resolveTo(msg.to)].filter((t) => t !== msg.from); // Targets that actually took delivery. The log below reports these instead of // intent, so a bounced or dropped message can never read as delivered. @@ -1698,17 +1807,17 @@ export class HiveManager { // The send-only prep assistant must never be a delivery target: it doesn't // drain an inbox, so direct mail to it would rot unread (observed live: a // task brief plus the follow-up reprimand about the unread inbox, both - // unread for hours). Bounce such mail to god instead, so the sender's intent + // unread for hours). Bounce such mail to orchestrator instead, so the sender's intent // surfaces immediately and nothing is silently lost. if (reg.agents[t]?.isAssistant) { - this.bounceToGod(msg, godId, `[bounced — "${t}" is the send-only prep assistant; route work to a real agent] ${msg.subject}`); + this.bounceToOrchestrator(msg, orchestratorId, `[bounced — "${t}" is the send-only prep assistant; route work to a real agent] ${msg.subject}`); continue; } // An ARCHIVED recipient (its terminal is gone) still has an inbox, so the // mail lands there and reads as delivered — and stays unread. Filing it // is right: a worker re-hired under the same id reads it on its first turn // (a released worker's inbox is settled only up to its done signal). But - // the sender learned nothing, so god kept mailing dead agents for hours + // the sender learned nothing, so orchestrator kept mailing dead agents for hours // (seen live 2026-08-16), and a request to a worker nobody re-hires was // simply lost. Now mail that expects an answer is filed AND its sender is // told, at once, that no one is there to answer it. Inform-only mail @@ -1717,7 +1826,7 @@ export class HiveManager { // work order to, whatever the engine — the inbox is the only place left. // The notice itself is ROUTED, not dropped into an inbox, so a sender on // a hookless or proxy-tier engine gets it the way it gets any mail. - if (t !== godId && reg.agents[t]?.archived) { + if (t !== orchestratorId && reg.agents[t]?.archived) { if (this.deliver(msg, t)) delivered.push(t); this.appendLog({ kind: 'archived-recipient', from: msg.from, to: t, id: msg.id, act: msg.act }); const sender = reg.agents[msg.from]; @@ -1741,11 +1850,11 @@ export class HiveManager { // would let direct mail rot unread. Claude and bridged Antigravity/Codex // receive directly into inbox/ for guarded renderer delivery. Otherwise try // a terminal work-order handoff to its REPL (#53); - // if the renderer is unavailable, bounce to god to relay. God is exempt + // if the renderer is unavailable, bounce to orchestrator to relay. Orchestrator is exempt // (the bounce target). - if (t !== godId && !canReceiveInbox(reg.agents[t]?.provider)) { + if (t !== orchestratorId && !canReceiveInbox(reg.agents[t]?.provider)) { if (!this.emitTerminalHandoff(msg, t)) { - this.bounceToGod(msg, godId, `[undeliverable — "${t}" runs ${reg.agents[t]?.provider ?? 'a hookless CLI'} and the terminal handoff failed (renderer unavailable); relay this to it] ${msg.subject}`); + this.bounceToOrchestrator(msg, orchestratorId, `[undeliverable — "${t}" runs ${reg.agents[t]?.provider ?? 'a hookless CLI'} and the terminal handoff failed (renderer unavailable); relay this to it] ${msg.subject}`); } else delivered.push(t); continue; } @@ -1755,9 +1864,9 @@ export class HiveManager { // mail rides the terminal work-order path verbatim, exactly like a hookless // provider; the synthesized Stop→drain keeps the cursor in step. const proxyDesc = bridgeOf(reg.agents[t]?.provider); - if (t !== godId && proxyDesc?.kind === 'proxy' && proxyDesc.inboxDelivery === 'terminal') { + if (t !== orchestratorId && proxyDesc?.kind === 'proxy' && proxyDesc.inboxDelivery === 'terminal') { if (!this.emitTerminalHandoff(msg, t)) { - this.bounceToGod(msg, godId, `[undeliverable — "${t}" runs ${reg.agents[t]?.provider ?? 'a proxy-tier CLI'} and the terminal handoff failed (renderer unavailable); relay this to it] ${msg.subject}`); + this.bounceToOrchestrator(msg, orchestratorId, `[undeliverable — "${t}" runs ${reg.agents[t]?.provider ?? 'a proxy-tier CLI'} and the terminal handoff failed (renderer unavailable); relay this to it] ${msg.subject}`); } else delivered.push(t); continue; } @@ -1765,16 +1874,16 @@ export class HiveManager { // No agents//inbox — an id that isn't on the floor. This was the one // delivery failure with neither bounce nor log, so the sender saw a routed // message and the mail simply ceased to exist. Record the drop beside the - // hop-cap one and bounce to god, mirroring the undeliverable bounces above. + // hop-cap one and bounce to orchestrator, mirroring the undeliverable bounces above. this.appendLog({ kind: 'drop', reason: 'no-inbox', from: msg.from, to: t, id: msg.id }); - if (t !== godId) { - this.bounceToGod(msg, godId, `[undeliverable — no agent "${t}" on this floor; check the id against the roster] ${msg.subject}`); + if (t !== orchestratorId) { + this.bounceToOrchestrator(msg, orchestratorId, `[undeliverable — no agent "${t}" on this floor; check the id against the roster] ${msg.subject}`); } } this.appendLog({ kind: 'message', from: msg.from, to: msg.to, act: msg.act, subject: msg.subject, id: msg.id, delivered }); this.emitMessage(msg, targets); // Main-process observer (e.g. the closing-time controller watching for the - // team's ACKs and the god's COMPLETE). Best-effort, never breaks routing. + // team's ACKs and the orchestrator's COMPLETE). Best-effort, never breaks routing. try { this.routedObserver?.(msg, targets); } catch { /* observer error */ } } @@ -1796,7 +1905,7 @@ export class HiveManager { subject: msg.subject, targets, // Coral-tints the floor envelope for a message the agent flagged for the - // human (now routed to the god proxy). Cosmetic only — no queue behind it. + // human (now routed to the orchestrator proxy). Cosmetic only — no queue behind it. needsHuman: msg.to === 'human' }); } @@ -1896,8 +2005,8 @@ 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: {} }); + if (!root) return { orchestratorId: null, agents: {} }; + return this.readJson(join(root, 'registry.json'), { orchestratorId: null, agents: {} }); } board(): string { const root = this.root(); @@ -1912,7 +2021,7 @@ export class HiveManager { * board/message persist pattern: write JSON, log the change, single-commit. * * MERGES by card id instead of clobbering. Callers hold PARTIAL models of a - * card — the renderer's kanban parser knows nine fields, the god writes as + * card — the renderer's kanban parser knows nine fields, the orchestrator writes as * many as the work needs (`result`, the verbatim Slack reply posted back to * the user; `repo`; `scope`; `origin`; `commit`; …). A wholesale write meant * one small edit through the UI deleted every unmodelled field on EVERY card @@ -1935,7 +2044,7 @@ export class HiveManager { /** Append one card against the latest on-disk ledger. Renderer callers must * use this instead of re-writing a collection they read before another - * source (webhook, Slack, god, voice) added work. Idempotent by task id. */ + * source (webhook, Slack, orchestrator, voice) added work. Idempotent by task id. */ addTask(task: HiveTask): boolean { const ledger = this.tasks() as { tasks?: HiveTask[] }; const tasks = Array.isArray(ledger?.tasks) ? ledger.tasks : []; @@ -2415,7 +2524,7 @@ export class HiveManager { return home; } - /** OpenCode (anomalyco/opencode) bridge — god Decision 1 (native plugin, not proxy). + /** OpenCode (anomalyco/opencode) bridge — orchestrator Decision 1 (native plugin, not proxy). * OpenCode has no Claude-shaped Stop hook, but its plugin API exposes a real * `session.idle` lifecycle event. We drop a bundled PLUGIN into a PER-AGENT config * dir's `plugin/` folder (OpenCode auto-loads `*.js` plugins from there) that posts @@ -2485,9 +2594,9 @@ export class HiveManager { // hit the wrong endpoint and the call would fail. Those are left to their real // upstreams (working calls, un-proxied — no synthesized events, but mail still // drains via the renderer nudge + the pty-quiescence idle fallback). For the - // default god (openai-wire) and a local OpenAI-compatible endpoint this routes + // default orchestrator (openai-wire) and a local OpenAI-compatible endpoint this routes // through the proxy cleanly. Cross-provider Crush-via-proxy is on-device - // live-verify (Dwight verify-crush MF1; the default god model is openai-wire to + // live-verify (Dwight verify-crush MF1; the default orchestrator model is openai-wire to // match). Literal loopback (Dwight's b1 — no ${VAR} expansion edge cases); // Crush merges config so only base_url is rewritten. const wireProvider = api === 'anthropic' ? 'anthropic' : 'openai'; @@ -2546,7 +2655,7 @@ export class HiveManager { } catch (e) { console.error('[hive] installGrokHooks failed:', e); } } - /** Write the live fleet snapshot Michael reads (`fleet.json`, gitignored). + /** Write the live fleet snapshot the orchestrator reads (`fleet.json`, gitignored). * Best-effort — called from a timer, must never throw. */ writeFleetSnapshot(snapshot: unknown): void { const root = this.root(); @@ -2554,29 +2663,72 @@ export class HiveManager { try { writeFileSync(join(root, 'fleet.json'), JSON.stringify(snapshot, null, 2), 'utf8'); } catch { /* noop */ } } - /** Is this agent the hive's god/orchestrator? */ - isGod(agentId: string): boolean { + /** Write the provider-usage snapshot the orchestrator reads (`usage-availability.json`). + * Gitignored with the other live files. Never throws. */ + writeUsageAvailability(snapshot: unknown): void { + const root = this.root(); + if (!root) return; + try { writeFileSync(join(root, 'usage-availability.json'), JSON.stringify(snapshot, null, 2), 'utf8'); } catch { /* noop */ } + } + + /** Is this agent the hive's orchestrator? */ + isOrchestrator(agentId: string): boolean { try { const reg = this.registry(); - return reg.godId === agentId || !!reg.agents[agentId]?.isGod; + return reg.orchestratorId === agentId || !!reg.agents[agentId]?.isOrchestrator; } catch { return false; } } + /** GOD-STARTUP-TOKENS R1: orchestrator spawn chose a fresh start; its first answering hook gets the + * handoff. In memory: a restart before that hook makes the same decision again. */ + private godHandoff = new Map(); + armGodHandoff(agentId: string, h: { reasons: string[]; previousSession: string | null; contextTokens: number | null }): void { + this.godHandoff.set(agentId, { ...h, reasons: [...h.reasons], at: Date.now() }); + this.appendLog({ kind: 'god-handoff-armed', agentId, reasons: h.reasons, previousSession: h.previousSession }); + } + + /** R1: the armed handoff as context (and disarmed), or null. Reads orchestrator's memory.md, the floor + * digest and the board status from disk now, so the handoff is current. */ + takeGodHandoff(agentId: string, others: Array = []): string | null { + const h = this.godHandoff.get(agentId); + if (!h) return null; + this.godHandoff.delete(agentId); + if (Date.now() - h.at > HiveManager.GOD_HANDOFF_STALE_MS) { + this.appendLog({ kind: 'god-handoff-dropped', agentId, reasons: h.reasons, ageMs: Date.now() - h.at }); + return null; + } + const joined = others.filter((x): x is string => typeof x === 'string' && x.length > 0); + const budget = 9500 - (joined.length ? joined.join('\n\n').length + 2 : 0); + const root = this.root(); + const read = (p: string | null): string | null => { try { return p && existsSync(p) ? readFileSync(p, 'utf8') : null; } catch { return null; } }; + const { text, cut } = godHandoffFit({ + reasons: h.reasons, previousSession: h.previousSession, contextTokens: h.contextTokens, + memory: read(root ? join(this.agentDir(agentId), 'memory.md') : null), + floorDigest: read(root ? join(root, 'floor-digest.md') : null), + boardStatus: read(root ? join(root, 'board-status.md') : null) + }, budget); + this.appendLog({ kind: 'god-handoff-delivered', agentId, chars: text.length, budget, cut, reasons: h.reasons }); + return text || null; + } + + /** Creed N1: an armed handoff older than this was for a session that never started. */ + static readonly GOD_HANDOFF_STALE_MS = 10 * 60 * 1000; + /** * A compact, one-shot LIVE ROSTER line built from `fleet.json` — injected into - * god's context as `additionalContext` on SessionStart and every + * orchestrator's context as `additionalContext` on SessionStart and every * UserPromptSubmit (see HookServer). * * Why: fleet.json/registry.json are always fresh on disk (8s snapshot + - * archiveOrphanedAgents on boot + PTY-exit archiving), but god's CONTEXT is not. - * After an app restart god resumes a session whose transcript still describes + * archiveOrphanedAgents on boot + PTY-exit archiving), but orchestrator's CONTEXT is not. + * After an app restart orchestrator resumes a session whose transcript still describes * the OLD floor, and it will happily message agents that no longer exist. It is * told to read fleet.json, but "told to" is not "always knows" — so we push the * truth in on every turn instead. One line, so the cost is negligible. * * `ctxOf` (optional, supplied by HookServer) lets the caller layer the LIVE * context-window occupancy on top of the disk snapshot — each agent gets a - * `ctx NN%` so god can see at a glance whose context is nearly full when it + * `ctx NN%` so orchestrator can see at a glance whose context is nearly full when it * routes work. fleet.json only carries cumulative `tokens`, which is a spend * figure, not how full the CURRENT window is; the real occupancy lives in * HookServer.contextById (from the statusLine shim). Omitted when the callback @@ -2595,7 +2747,7 @@ export class HiveManager { const snap = JSON.parse(raw) as { ts?: number; agents?: Array<{ - id: string; name?: string; role?: string; isGod?: boolean; + id: string; name?: string; role?: string; isOrchestrator?: boolean; breaker?: string; tokens?: number; usd?: number; lastTool?: string | null; lastActiveSecAgo?: number | null; inboxBacklog?: number; onHold?: boolean; @@ -2623,12 +2775,12 @@ export class HiveManager { if (a.usd) bits.push(`$${a.usd.toFixed(2)}`); if (a.inboxBacklog) bits.push(`inbox ${a.inboxBacklog}`); if (a.breaker && a.breaker !== 'ok' && a.breaker !== 'none') bits.push(`breaker ${a.breaker}`); - if (a.isGod) bits.push('you'); + if (a.isOrchestrator) bits.push('you'); // First in the row after the role would be louder, but this reads in - // the same scan as `breaker` and `inbox`, and god already treats those + // the same scan as `breaker` and `inbox`, and orchestrator already treats those // as routing signals. if (a.onHold) { bits.push('ON HOLD — 1:1 with the human'); anyHold = true; } - // Live context-window occupancy from the statusLine shim — lets god see + // Live context-window occupancy from the statusLine shim — lets orchestrator see // which agents are near-full when routing, instead of guessing from the // cumulative token count. Clamp to 0-100; a fresh meter can briefly // report more than 100% before a window rotation. @@ -2662,9 +2814,53 @@ export class HiveManager { } logTail(n = 200): unknown[] { const root = this.root(); - if (!root || !existsSync(join(root, 'log.jsonl'))) return []; - const lines = readFileSync(join(root, 'log.jsonl'), 'utf8').trim().split('\n').filter(Boolean); - return lines.slice(-n).map((l) => { try { return JSON.parse(l); } catch { return { raw: l }; } }); + if (!root || n <= 0) return []; + const live = join(root, 'log.jsonl'); + // LOG-STALL-AV: the log rotates, so a tail that the live file cannot fill continues into + // the rotated files, newest first. Each is read with the same bounded tail window. + const files = [...rotatedFiles(live).map((r) => r.path), ...(existsSync(live) ? [live] : [])]; + const parse = (l: string): unknown => { try { return JSON.parse(l); } catch { return { raw: l }; } }; + let rows: unknown[] = []; + for (let i = files.length - 1; i >= 0 && rows.length < n; i--) { + rows = [...this.fileTail(files[i], n - rows.length, parse), ...rows]; + } + return rows; + } + + /** The last `n` rows of one log file (bounded tail read; the logic logTail always had). */ + private fileTail(file: string, n: number, parse: (l: string) => unknown): unknown[] { + if (n <= 0 || !existsSync(file)) return []; + try { + const size = statSync(file).size; + if (size === 0) return []; + let fd: number | null = null; + try { + fd = openSync(file, 'r'); + // Grow the window until it holds n+1 line starts (so we know the first line in it + // is whole) or we have read the entire file. + for (let want = Math.min(size, LOG_TAIL_WINDOW_BYTES); ; want = Math.min(size, want * 4)) { + const from = size - want; + const buf = Buffer.alloc(want); + readSync(fd, buf, 0, want, from); + let text = buf.toString('utf8'); + // A window that starts mid-file almost certainly starts mid-LINE. Drop that + // fragment: keeping it would hand back a corrupt row, and JSON.parse failing on + // it would surface as a bogus {raw} event rather than an error anyone notices. + if (from > 0) { + const nl = text.indexOf('\n'); + if (nl === -1) { if (want >= size) return []; continue; } + text = text.slice(nl + 1); + } + const lines = text.split('\n').filter(Boolean); + if (lines.length >= n || want >= size) return lines.slice(-n).map(parse); + } + } finally { if (fd !== null) closeSync(fd); } + } catch { + // Any read problem falls back to the whole-file path: correctness over speed, and a + // log small enough to be unreadable this way is small enough for it not to matter. + const lines = readFileSync(file, 'utf8').trim().split('\n').filter(Boolean); + return lines.slice(-n).map(parse); + } } private listMessages(dir: string): HiveMessage[] { @@ -2683,7 +2879,7 @@ export class HiveManager { * `{kind:'archive', agentId, archived:true}` and NOTHING else — no exit code, * no signal, no output. A two-second SIGILL death and a completed agent were * indistinguishable in the only record the hive keeps, and the crash banner - * lived solely in a UI terminal pane. Observed live 2026-08-24: Michael's + * lived solely in a UI terminal pane. Observed live 2026-08-24: the orchestrator's * `claude` CLI panicked 923ms into startup and left no trace on disk. * * Split by design: @@ -2751,7 +2947,8 @@ export class HiveManager { const root = this.root(); if (!root) return; const line = JSON.stringify({ ts: Date.now(), ...event }) + '\n'; - try { appendFileSync(join(root, 'log.jsonl'), line, 'utf8'); } catch { /* noop */ } + // A kept-open descriptor, rotated at 8 MB: no open/close (so no antivirus rescan) per row. + this.appendFileFor(join(root, 'log.jsonl'), LOG_KEEP_ROTATED).append(line); } /** @@ -2922,7 +3119,7 @@ export class HiveManager { // ─── Generated hive docs (written into the hive for every agent) ───────────── const GENERATED_DOC_NOTICE = - ''; + ''; /** The Claude Code command reference written to /COMMANDS.md, rendered from * the SAME source as the UI "commands" tab so they never drift. Leads with the @@ -2975,7 +3172,7 @@ Write one JSON file into \`outbox/\` (any filename ending in \`.json\`): \`\`\`json { - "to": " | god | broadcast", + "to": " | orchestrator | broadcast", "act": "request | inform | propose | query | agree | refuse | done", "subject": "one-line summary", "body": "the details", @@ -2989,26 +3186,26 @@ The harness fills in \`id\`, \`from\`, \`hops\`, and timestamps. ## Rules of the road - Only \`request\`, \`query\`, and \`propose\` expect a reply. \`inform\` and \`done\` are terminal — don't reply to them, or two agents will loop forever. -- For anything ambiguous, cross-cutting, or needing sign-off, message \`god\` — the - god agent clarifies answers for you so you rarely need the human directly. +- For anything ambiguous, cross-cutting, or needing sign-off, message \`orchestrator\` — the + orchestrator clarifies answers for you so you rarely need the human directly. - There is NO separate human-approval queue. Human-in-the-loop is native to Claude Code: a tool you run that needs permission prompts in your own session (the human can approve it remotely from their phone via \`/remote-control\`). If you genuinely - need a human decision, raise it with \`god\` (a message \`"to": "human"\` is routed to - the god/orchestrator, the human's proxy on the floor). -- \`board.md\` is the shared plan. Don't edit it directly — \`propose\` changes to \`god\`, + need a human decision, raise it with \`orchestrator\` (a message \`"to": "human"\` is routed to + the orchestrator, the human's proxy on the floor). +- \`board.md\` is the shared plan. Don't edit it directly — \`propose\` changes to \`orchestrator\`, who is its sole scribe. - Re-reading a message you already moved to \`.done/\` is a no-op. Don't reprocess. ## The work: board.md vs tasks.json There are two shared surfaces, both in the hive root: -- \`board.md\` — the freeform narrative plan. The god agent is its sole scribe; others \`propose\` edits. +- \`board.md\` — the freeform narrative plan. The orchestrator is its sole scribe; others \`propose\` edits. - \`tasks.json\` — the structured task ledger (a kanban: \`todo / doing / blocked / done\`, with title, assignee, priority, deps). Keep the task you're working reflected in its status. ## Asking the human (the ASK ME card) When a card can only move with the human — a question to answer, or an action only they can do -(create an account, approve a spend, hand over credentials, test on their device) — the god sets the +(create an account, approve a spend, hand over credentials, test on their device) — the orchestrator sets the card \`"status": "blocked"\` and appends the ask to its \`humanQA\` array: \`\`\`json @@ -3016,7 +3213,7 @@ card \`"status": "blocked"\` and appends the ask to its \`humanQA\` array: \`\`\` The harness shows the open ask on the ASK ME board and in the ASK ME tab, and the human's reply lands -in the same entry as \`"a"\` plus an inbox message to god. Every past entry stays on the card — that +in the same entry as \`"a"\` plus an inbox message to orchestrator. Every past entry stays on the card — that trail is the decision history. **Write the ask short, and in markdown.** The card renders it, so plain-text asterisks and backticks @@ -3032,17 +3229,25 @@ When the ask originates in another agent's report, REWRITE it into that shape. N body in as the question, and never make the human read the investigation to find the decision. Do NOT park human questions in separate files (no \`HumanQuestion.md\`), and never sit idle waiting for a reply — move on to other work and pick the answer up when it arrives. +## Usage availability (optional) +If \`usage-availability.json\` exists in the hive root, the human has configured a provider-usage +endpoint and the harness refreshes that file at every standup. It reports, per engine, the percent of +each allowance window left (e.g. \`five_hour\`, \`weekly\`), when each window resets, and an +\`availability\` word. Use it when routing: avoid sending new work to an engine that is nearly out, +and when a window is about to reset with plenty unused, lean on that engine for work that can wait +no longer. Treat a stale \`timestamp\` or a missing engine as "unknown", not "empty". + ## Guardrails: circuit breaker & token budgets A circuit breaker watches every agent for runaway behavior (looping on the same tool, error storms, overspending). It escalates gently: \`steer\` → \`constrain\` → \`stop\`. If a \`Circuit breaker: steer\` or \`Circuit breaker: constrain\` message lands in your inbox, you ARE the problem it caught — stop repeating, summarize what you've tried, and do exactly what the message says (constrain = go read-only -and get god's sign-off before more tool calls). Be **token-frugal**: the floor has a token budget and +and get orchestrator's sign-off before more tool calls). Be **token-frugal**: the floor has a token budget and each agent can have its own token limit; crossing it trips the breaker. Prefer references over pasted content, and \`/compact\` your own session when context gets heavy. ## Fleet monitoring (orchestrator) -You (god) are responsible for situational awareness. To see the live state of every agent, read +You (orchestrator) are responsible for situational awareness. To see the live state of every agent, read \`fleet.json\` in the hive root — it is refreshed continuously with each agent's tokens, cost, status, breaker level, last tool, last-active time, and inbox backlog. Pair it with \`registry.json\` (the roster) and \`log.jsonl\` (the event feed). IMPORTANT: \`claude agents\` will NOT show your hive's sibling @@ -3066,7 +3271,7 @@ the hive root: "isolate": true, "tokenCap": 0, "slack": { "channel": "C…", "thread_ts": "…" }, - "character": "meredith", + "character": "cleo", "accent": "coral" } \`\`\` @@ -3282,7 +3487,7 @@ module.exports.default = module.exports; `; // ─── opencode bridge plugin (written to /.opencode/plugin/) ──────── -// A bundled plugin for OpenCode (anomalyco/opencode) — god Decision 1. OpenCode +// A bundled plugin for OpenCode (anomalyco/opencode) — orchestrator Decision 1. OpenCode // has no Claude-shaped Stop hook but its plugin API exposes a real session.idle // event; this posts cth-hook-shaped payloads to HIVE_SOCK on tool.execute.before/ // after + session.idle. The session.idle→Stop keeps status in step (→ idle) so the diff --git a/src/main/hooks.ts b/src/main/hooks.ts index 4b65b05fd9..03601a53dd 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -15,7 +15,8 @@ import { existsSync, rmSync, statSync } from 'node:fs'; import { randomUUID } from 'node:crypto'; import { Notification, type WebContents } from 'electron'; import type { HiveManager } from './hive'; -import type { HarnessConfig } from './config'; +import { classifyHeavy, commandFromToolInput, isBackground, type HeavyJobLock } from './heavyJob'; +import { modelForHiveSpawn, type HarnessConfig } from './config'; import type { ControlRegistry } from './control'; import type { CircuitBreaker } from './breaker'; import { estimateCostUsd } from './pricing'; @@ -43,9 +44,8 @@ interface HookPayload { /** Notification hook text, e.g. "Claude is waiting for your input" (idle) vs a * permission request. Used to tell "needs you" from "just done / lingering". */ message?: string; - /** CostSample payloads only (synthesized by the proxy-bridge sidecar for - * qwen). Raw token counts for one response, fed to the cost ledger. */ - model?: string; + /** Status payloads carry Claude's model object; CostSample uses a string. */ + model?: string | { id?: unknown }; input?: number; output?: number; cache_read?: number; @@ -57,7 +57,7 @@ interface HookPayload { * it the shims' connect() fails and they exit 0 with empty stdout, which the * CLI reads as "allow": the breaker is inert, fleet.json never appears, no cost * is recorded — and until #277 nothing said so. The beat writes this into - * fleet.json so an operator (or god) can see it without a debugger. */ + * fleet.json so an operator (or orchestrator) can see it without a debugger. */ export interface HookSocketHealth { /** HIVE_SOCK — where the shims connect. null while the hive has no root. */ path: string | null; @@ -380,6 +380,18 @@ export class HookServer { return this.contextById.get(agentId); } + /** HEAVY-JOB-SERIALIZE: the app-held heavy-job lock (main wires it; null in tests = no lock). */ + private heavyLock: HeavyJobLock | null = null; + setHeavyLock(lock: HeavyJobLock | null): void { this.heavyLock = lock; } + + /** The id that pairs a heavy call's PreToolUse with its PostToolUse: the provider's tool-use + * id when it sends one, else the command itself (identical in both hooks). */ + private static heavyCallId(p: HookPayload): string { + const id = (p as { tool_use_id?: unknown }).tool_use_id; + if (typeof id === 'string' && id) return `id:${id}`; + return `cmd:${(commandFromToolInput(p.tool_input) ?? '').slice(0, 500)}`; + } + private handle(p: HookPayload): unknown { const agentId = p.agent_id ?? undefined; const event = p.hook_event_name ?? 'Unknown'; @@ -395,10 +407,22 @@ export class HookServer { // statusLine shim, not a real hook boundary — it must never trip the // HALT gate or feed the breaker's loop detector below. The early return // also (deliberately) skips recordSession for status ticks: a statusLine - // payload's session_id adds nothing the real hooks don't already record, - // and telemetry should never write to the registry. transcript_path IS - // still captured above, where every payload shape benefits from it. + // payload's session_id adds nothing the real hooks don't already record. + // The model is the exception: it is the authoritative per-agent `/model` + // observation and must survive a restart. transcript_path IS still captured + // above, where every payload shape benefits from it. if (event === 'Status') { + const statusModel = typeof p.model === 'object' && p.model !== null && typeof p.model.id === 'string' + ? p.model.id.trim() + : ''; + if (agentId && statusModel) { + const agent = this.hive.registry().agents[agentId]; + // Do not let a bridged provider's display model become a future Claude + // argv. `recordModel` repeats this gate at the persistence boundary. + if (agent?.provider === 'claude') { + this.hive.recordModel(agentId, statusModel, modelForHiveSpawn(agent, this.getConfig())); + } + } const cw = p.context_window; if (agentId && cw && typeof cw.total_input_tokens === 'number' && typeof cw.context_window_size === 'number' && cw.context_window_size > 0) { @@ -438,6 +462,7 @@ export class HookServer { // accounting schema uniform). Pure telemetry — never feeds the loop detector. if (event === 'CostSample') { if (agentId && p.session_id) { + const model = typeof p.model === 'string' ? p.model : ''; const input = p.input ?? 0; const output = p.output ?? 0; const cacheRead = p.cache_read ?? 0; @@ -450,8 +475,8 @@ export class HookServer { output, cacheRead, cacheCreation, - model: p.model ?? '', - usd: estimateCostUsd(p.model, { + model, + usd: estimateCostUsd(model, { inputTokens: input, outputTokens: output, cacheReadTokens: cacheRead, @@ -518,6 +543,27 @@ export class HookServer { } } + // HEAVY-JOB-SERIALIZE: (AFTER the HITL gate above, so a call the control layer denies never holds a + // slot: a denied call gets no PostToolUse to free it.) A heavy command (install / build / full suite / bench) takes one of the + // machine's heavy-job slots, or is DENIED naming the holders. Every provider reaches this deny + // (Claude http, Codex mcp, the AGY shim's deny translation). A subagent's call counts as its + // agent's. Settings "Heavy jobs at once" = Off makes this do nothing. + if (event === 'PreToolUse' && agentId && this.heavyLock) { + const cls = classifyHeavy(p.tool_name, p.tool_input); + if (cls.heavy) { + const d = this.heavyLock.acquire(agentId, cls, commandFromToolInput(p.tool_input) ?? '', HookServer.heavyCallId(p), isBackground(p.tool_input)); + if (!d.allow) { + this.emitControl(agentId, p.tool_name, d.reason); + this.emit(agentId, event, p); + return { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: d.reason } }; + } + } + } + // A heavy FOREGROUND call returned: its job is done (a backgrounded one is left to the watcher). + if (event === 'PostToolUse' && agentId && this.heavyLock && classifyHeavy(p.tool_name, p.tool_input).heavy) { + this.heavyLock.callDone(agentId, HookServer.heavyCallId(p)); + } + // 7C.2 — mid-run steering: inject queued operator guidance as context on the // next eligible hook (no fragile typing into the TUI). Delivered once. // Merged with the roster line below so the two injections never displace each @@ -527,21 +573,27 @@ export class HookServer { steer = this.control.takeSteer(agentId) ?? null; } - // Keep god's roster CURRENT. fleet.json is always fresh on disk, but god's + // Keep orchestrator's roster CURRENT. fleet.json is always fresh on disk, but orchestrator's // context is not: after a restart it resumes a transcript describing the old // floor and messages agents that are long gone. Push the live roster in as - // additionalContext at the start of each session and on every prompt, so god + // additionalContext at the start of each session and on every prompt, so orchestrator // knows the floor all the time instead of only when it remembers to Read. - // God-only and one line — every other agent is unaffected. + // Orchestrator-only and one line — every other agent is unaffected. const wantsRoster = (event === 'SessionStart' || event === 'UserPromptSubmit') - && !!agentId && this.hive.isGod(agentId); + && !!agentId && this.hive.isOrchestrator(agentId); // Hand the roster the LIVE context-window occupancy (contextById) so each - // agent line can carry a `ctx NN%` — god then sees whose context is nearly + // agent line can carry a `ctx NN%` — orchestrator then sees whose context is nearly // full when it routes work, instead of guessing from cumulative token spend. const roster = wantsRoster ? this.hive.rosterContext((id) => this.contextFor(id)) : null; + // GOD-STARTUP-TOKENS R1: an orchestrator that started FRESH gets its bounded handoff + // once, at that session's SessionStart (source "startup"). + const handoff = wantsRoster && event === 'SessionStart' && p.source === 'startup' && (p as any).transport !== 'pipe-oneway' + ? (this.hive as any).takeGodHandoff?.(agentId) ?? null + : null; + // Standing goal (hire Briefing) — durable roster field, re-read every cycle so // an Edit Agent save is picked up on the next UserPromptSubmit without // restarting the worker. Deliver it once at SessionStart, then only when its @@ -569,12 +621,12 @@ export class HookServer { } } - if (steer || roster || goal) { + if (handoff || steer || roster || goal) { this.emit(agentId, event, p); return { hookSpecificOutput: { hookEventName: event, - additionalContext: [roster, goal, steer].filter(Boolean).join('\n\n') + additionalContext: [handoff, roster, goal, steer].filter(Boolean).join('\n\n') } }; } diff --git a/src/main/index.ts b/src/main/index.ts index 0cc017c8d6..160833f712 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,9 +1,9 @@ import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, powerMonitor, powerSaveBlocker, screen, shell, Notification } from 'electron'; -import { spawn } from 'node:child_process'; +import { spawn, execFile } from 'node:child_process'; import { rmSync, existsSync, readFileSync, readdirSync, statSync, cpSync, writeFileSync, unlinkSync, mkdirSync, renameSync, createWriteStream, copyFileSync, lstatSync, - readlinkSync, symlinkSync + readlinkSync, symlinkSync, appendFileSync } from 'node:fs'; import { randomBytes, createHash, timingSafeEqual } from 'node:crypto'; import { join, resolve, sep, basename, dirname, isAbsolute } from 'node:path'; @@ -13,9 +13,10 @@ import { PtyManager, type SpawnOptions } from './pty'; import { resolveCommand as resolveCliCommand, isSafeCommandName } from './shellEnv'; import { initAutoUpdater, abortPendingRestart } from './updater'; import { RealtimeFloorWatcher } from './realtimeFloorWatcher'; +import { ProviderModelStore, defaultAdapters } from './providerModels'; import { readConfig, writeConfig, setAgentTokenCap, resetConfig, onConfigWritten, ensureHarnessHome, ensureClaudePermissionsAccepted, - modelForRole, OPS_STANDUP_MISSION, HEARTBEAT_MISSION, COMPACT_MAINTENANCE_MISSION, type HarnessConfig, type ScheduledMission + modelForHiveSpawn, OPS_STANDUP_MISSION, HEARTBEAT_MISSION, COMPACT_MAINTENANCE_MISSION, type HarnessConfig, type ScheduledMission } from './config'; import { listDir, readFileText, readFileBinary, writeFileText, statAbs, expandTilde } from './fs'; import { normalizeWeekly, weeklyDelayMs } from '../shared/weeklySchedule'; @@ -27,13 +28,17 @@ import { import { linkWorktreeDeps, unlinkWorktreeDeps } from './worktreeDeps'; import { HiveManager, type AgentMeta, type HiveMessage, type HiveTask } from './hive'; import { HookServer } from './hooks'; +import { HeavyJobLock, heavyLimit, probeProcesses } from './heavyJob'; import { CircuitBreaker, type BreakerInput } from './breaker'; import { CumulativeSampleGate, type UsageProvider } from './usage'; +import { fetchUsage } from './usageEndpoint'; +import { summarizeForOrchestrator, normalizeUsageEndpoint } from '../shared/usageEndpoint'; import { MemoryManager } from './memory'; import { KnowledgeManager } from './knowledge'; import { MemoryReflector, type ReflectSettings } from './reflect'; import { PersistStore } from './db'; -import { readAgentUsage, readContextTokens, seedSessionTranscript, resolveSessionCwd } from './transcript'; +import { readAgentUsage, readContextTokens, seedSessionTranscript, resolveSessionCwd, sessionTranscriptPath } from './transcript'; +import { godFreshStart, readClaudeVersion, type GodFreshReason } from './godStartup'; import { listIssues, listCIRuns } from './github'; import { SlackWebhookServer, SlackReplyServer, postSlackReply, type SlackEventFile } from './slack'; import { @@ -66,7 +71,7 @@ import { buildWorkerLaunch } from './workerLaunch'; import { ControlRegistry } from './control'; import { WorkerWakeWatchdog, WORKER_WAKE_REPORT_MS, activityEvidenceAt, type WorkerWakeFacts } from './workerWake'; import { inboxNudgeText } from '../shared/hiveNudge'; -import { resolveGodName } from '../shared/godIdentity'; +import { resolveOrchestratorName, setThemeOrchestratorName, getThemeOrchestratorName } from '../shared/orchestratorIdentity'; import { fetchHireManifest, readHireManifestFiles } from './hire'; import { parseHireDeepLink, type HireManifest } from '../shared/hire'; import { ClosingTimeController } from './closingTime'; @@ -84,7 +89,6 @@ import { detectNodeVersion, nodeIsUsable, resolveNodeInstaller } from './nodeIns import { toolCatalog, type ToolStatus } from '../shared/toolCatalog'; import { listLocalSkills, loadCatalog, installSkill, uninstallSkill, type LocalSkill } from './skills'; import { loadHero } from './hero'; -import { loadModelCatalog } from './modelCatalog'; import { CODEX_REMOTE_SOCKET_RELATIVE, codexRemoteAliasPath, @@ -92,6 +96,17 @@ import { codexRemoteSocketFits, withCodexRemoteArgs } from '../shared/codexRemote'; +import { scanThemes, readPack, validateThemePack, getThemeSearchPaths, readThemeOrchestratorName, setConfiguredThemesDir } from './themes'; +import { + runStandupTick, projectTasks, + type FloorState, type StandupDecision, type StandupSkipRecord +} from './standupDelta'; +import { billedEquivalentTokens, rawTokens } from '../shared/tokenWeights'; + +// Dev isolation: apply MD_USER_DATA_DIR early before config, db, or hive initialize +if (process.env.MD_USER_DATA_DIR && typeof process.env.MD_USER_DATA_DIR === 'string') { + app.setPath('userData', resolve(process.env.MD_USER_DATA_DIR)); +} const isDev = !!process.env.ELECTRON_RENDERER_URL; @@ -234,13 +249,19 @@ const ptyToAgent = new Map(); * no user click. Cleared the moment it's consumed, so it can never loop installs. */ const pendingInstallRelaunch = new Map(); const hive = new HiveManager( - () => readConfig().harnessHome, + () => process.env.MD_HIVE_ROOT || readConfig().harnessHome, (channel, payload) => { const wc = liveWebContents(); if (!wc) return false; try { wc.send(channel, payload); return true; } catch { return false; } } ); +// LOG-STALL-AV: the app keeps log.jsonl / cost-ledger.jsonl open (closed on reset, change-home, quit). +hive.keepAppendFilesOpen(true); +if (process.env.MD_HOOKS_PIPE) { + const customPipe = process.env.MD_HOOKS_PIPE; + hive.sockPath = () => customPipe; +} // #7C — operator control state (pause/gate/steer/halt), read by the HookServer // when deciding hook returns. const control = new ControlRegistry(); @@ -274,7 +295,7 @@ const breaker = new CircuitBreaker(() => { return { ...(c.circuitBreaker ?? {}), costCapUsd: c.costCapUsd, costCapTokens: c.costCapTokens, agentTokenCaps: c.agentTokenCaps }; }); // Always-on beats (decoupled from the optional heartbeat): the live fleet snapshot -// Michael reads + the breaker beat, so guardrails + monitoring work even when the +// the orchestrator reads + the breaker beat, so guardrails + monitoring work even when the // heartbeat mission is disabled (it ships off). let fleetTimer: ReturnType | null = null; let breakerBeatTimer: ReturnType | null = null; @@ -311,6 +332,16 @@ const hookServer = new HookServer( standingGoalFromRoster, (agentId, event, message) => workerWake.noteHook(agentId, event, message) ); +// HEAVY-JOB-SERIALIZE: the machine's heavy-job slots (Settings "Heavy jobs at once", read live). +// PreToolUse takes or denies a slot; a background job's slot is freed by a hidden process check +// that runs ONLY while a slot is held; PTY exit and a TTL free the rest. Holders go to fleet.json. +const heavyLock = new HeavyJobLock({ + limit: () => heavyLimit(readConfig().heavyJobsAtOnce), + roots: () => ptyManager.list().flatMap((s) => { const a = ptyToAgent.get(s.id); return a && s.pid > 0 ? [{ agentId: a, pid: s.pid }] : []; }), + probe: probeProcesses, + log: (row) => { try { hive.appendLog(row); } catch { /* best effort */ } } +}); +hookServer.setHeavyLock(heavyLock); const memory = new MemoryManager( () => readConfig().harnessHome, () => { const c = readConfig(); return { enabled: c.semanticMemory !== false, model: c.embeddingModel ?? 'minilm' }; } @@ -342,7 +373,7 @@ const reflector = new MemoryReflector( // Durable harness state (SQLite, main process). Phase A: window bounds (kv) + // net-new command history. Opened in whenReady, closed in the teardown blocks. const persist = new PersistStore(); -/** The PRIMARY window — the one running the hive/god orchestration and the sink +/** The PRIMARY window — the one running the hive/orchestrator orchestration and the sink * for process-global timer events (missions, breaker, Slack ingestion). It is * the most-recently-focused live window, so global events follow the user. * Additional "floor" windows are tracked in `allWindows` below. */ @@ -365,7 +396,7 @@ const worktreePaths = new Map(); * `git worktree remove` from the parent tree, not the worktree itself). */ const worktreeOrigins = new Map(); -/** A live god-triggered ephemeral worker, tracked from spawn to teardown. */ +/** A live orchestrator-triggered ephemeral worker, tracked from spawn to teardown. */ interface WorkerRec { workerId: string; // == the PTY id == hive agent id (`worker-`) reqId: string; // the spawn-request id @@ -380,7 +411,7 @@ interface WorkerRec { } /** Live ephemeral workers by id. Populated by the spawn-request watcher; consulted * by teardownPty so a finished/crashed/reaped worker's worktree is PRESERVED (not - * force-removed) when it holds unintegrated work — god is the sole integrator. */ + * force-removed) when it holds unintegrated work — orchestrator is the sole integrator. */ const liveWorkers = new Map(); /** The loopback secret broker (Phase 2). Workers reach registered integrations through @@ -456,6 +487,8 @@ function teardownPty(id: string): void { ptyToAgent.delete(id); // Drop watchdog state so a dead agent can't get nudged or leak its grace. try { workerWake.forget(agentId, id); } catch { /* best-effort */ } + // HEAVY-JOB-SERIALIZE: its jobs went with the PTY (unless another PTY of it is still alive). + if (![...ptyToAgent.values()].includes(agentId)) { try { heavyLock.agentGone(agentId); } catch { /* best-effort */ } } // Drop breaker state so a dead agent can't leak/zombie a tripped level. try { breaker.forget(agentId); } catch { /* best-effort */ } // A replacement using this id needs a new usage counter, not the dead PTY's. @@ -502,11 +535,11 @@ function teardownPty(id: string): void { syncKeepAwake(); } -/** Send an inform to the god agent (the human's proxy). The ephemeral-worker +/** Send an inform to the orchestrator (the human's proxy). The ephemeral-worker * controller uses this to surface every terminal failure AND to carry the Slack - * {channel,thread_ts} so god can post a 'couldn't complete' reply — closing the + * {channel,thread_ts} so orchestrator can post a 'couldn't complete' reply — closing the * Slack loop (the success path is the worker replying in-thread itself). */ -function informGod(subject: string, body: string, slack?: { channel: string; thread_ts: string }): void { +function informOrchestrator(subject: string, body: string, slack?: { channel: string; thread_ts: string }): void { try { const slackLine = slack // The bundled-node launcher, spelled as an ABSOLUTE PATH — NOT bare `node` @@ -515,14 +548,14 @@ function informGod(subject: string, body: string, slack?: { channel: string; thr // whole reply command was dead on Windows). ? `\n\n[SLACK] Close the loop — post a reply to channel ${slack.channel} thread ${slack.thread_ts} via:\n "${hive.nodeCommand()}" "${slackReplyScriptPath()}" --channel ${slack.channel} --thread ${slack.thread_ts} --text ""` : ''; - hive.send({ to: 'god', act: 'inform', subject, body: body + slackLine }, 'ephemeral-worker'); + hive.send({ to: 'orchestrator', act: 'inform', subject, body: body + slackLine }, 'ephemeral-worker'); } catch (e) { - console.error('[worker] informGod failed:', e); + console.error('[worker] informOrchestrator failed:', e); } } /** Gated worktree teardown for an ephemeral worker: remove it ONLY when it holds no - * unintegrated work; otherwise leave it (and its branch) in place and ping god, the + * unintegrated work; otherwise leave it (and its branch) in place and ping orchestrator, the * sole integrator. Async + best-effort; on any uncertainty it KEEPS the worktree * (fail-safe — never auto-discard possibly-valuable work). */ async function finalizeWorkerWorktree(wtPath: string, origCwd: string, worker: WorkerRec): Promise { @@ -538,7 +571,7 @@ async function finalizeWorkerWorktree(wtPath: string, origCwd: string, worker: W workerId: worker.workerId, wtPath, origCwd, baseBranch: worker.baseBranch, scratchDir: workerScratchDir(worker.workerId), slack: worker.slack, preservedAt: Date.now() }); - informGod( + informOrchestrator( `[worker worktree preserved] ${worker.workerId}`, `Ephemeral worker ${worker.workerId} ended but its worktree holds unintegrated work, so it was NOT auto-removed (you are the sole integrator).\n` + `Worktree: ${wtPath}\nBranch: ${work.branch}\nState: ${work.detail}\n` @@ -690,6 +723,90 @@ function clearMissionTimers(): void { missionTimers.clear(); } +/** True when this fire is the ops standup and the user turned the usage endpoint on. */ +function wantsUsage(m: ScheduledMission): boolean { + return m.id === OPS_STANDUP_MISSION.id && !!readConfig().usageEndpoint?.enabled; +} + +/** The standup body with the provider-usage summary appended. Never throws and never + * blocks past the endpoint's timeout, so an unreachable box costs one line, not a standup. */ +async function missionBodyWithUsage(m: ScheduledMission): Promise { + const cfg = readConfig().usageEndpoint; + if (!cfg?.enabled) return m.body; + const res = await fetchUsage(cfg); + if (!res.ok) return `${m.body} + +PROVIDER USAGE: the usage endpoint was unavailable (${res.error}). Treat remaining allowance as unknown.`; + hive.writeUsageAvailability({ fetchedAt: new Date().toISOString(), ...res.snapshot }); + return `${m.body} + +PROVIDER USAGE (remaining allowance per engine, from the user's usage endpoint; also in usage-availability.json): +${summarizeForOrchestrator(res.snapshot)}`; +} + +/** + * TE0 (standupDelta.ts): collect the inputs to the floor fingerprint. + * + * RULE 1 is enforced here: NOTHING is collected that is mtime-based or that is + * disturbed by the dispatch itself (orchestrator's inbox, then his .done/memory/outbox as + * he handles it, then every agent's files as the standup asks them to summarise + * and compact). Hashing any of them makes the gate see a delta after every + * standup and suppress nothing. + * + * Never throws, but never silently guesses either: anything it could not read is + * named in `unknown`, and an unknown floor dispatches. */ +function collectFloorState(): FloorState { + const unknown: string[] = []; + const agents: FloorState['agents'] = []; + let reg: ReturnType | null = null; + try { reg = hive.registry(); } catch { unknown.push('registry'); } + for (const [id, a] of Object.entries(reg?.agents ?? {})) { + if (a.archived) continue; + let actionableInbox = 0; + try { + actionableInbox = hive.inbox(id).filter((m) => !SYSTEM_SENDERS.has(m.from)).length; + } catch { + unknown.push(`inbox:${id}`); + } + agents.push({ + id, + onHold: !!a.onHold, + breaker: breaker.levelFor(id), + hasLivePty: !!ptyManager.list().some((t) => t.id === id), + actionableInbox + }); + } + const countDir = (label: string, p: string): number => { + try { return readdirSync(p).length; } catch (e) { + if ((e as NodeJS.ErrnoException)?.code === 'ENOENT') return 0; + unknown.push(label); + return 0; + } + }; + const root = hive.root(); + if (!root) unknown.push('hive-root'); + let tasks: FloorState['tasks'] = []; + try { tasks = projectTasks(hive.tasks()); } catch { unknown.push('tasks'); } + return { + agents, + tasks, + spawnRequests: root ? countDir('spawn-requests', join(root, 'spawn-requests')) : 0, + crashes: root ? countDir('crashes', join(root, 'crashes')) : 0, + unknown + }; +} + +/** Append the durable record of a SUPPRESSED standup. */ +function appendStandupSkip(record: StandupSkipRecord): void { + const root = hive.root(); + if (!root) return; + try { + appendFileSync(join(root, 'standup-skips.jsonl'), JSON.stringify(record) + '\n', 'utf8'); + } catch (e) { + console.error('[scheduler] skip record', record.missionId, e); + } +} + /** Rebuild the scheduler from persisted config: clear every existing timer, * then arm each enabled mission honoring its lastFiredAt — a setTimeout for the * time remaining until its next due fire, which then settles into a steady @@ -710,34 +827,50 @@ function syncMissions(): void { // with an adaptive cadence. Registered into the same missionTimers map so // clearMissionTimers() tears it down identically on quit/reset. if (m.kind === 'heartbeat') { armHeartbeat(m); continue; } - const fire = (): void => { + const fire = (forced = false): void => { try { + let gate: StandupDecision | null = null; // A 'compact' maintenance mission (maint-1) is compaction-ONLY: it carries // no dispatch body/target, so skip the hive.send and just fire auto-compact. // Gate on `kind!=='compact'` ALONE — that already excludes the compact mission; // we deliberately do NOT add `&& m.body`, so other (dispatch) missions keep // their prior behaviour, including the historical empty-body send (Pam N1). if (m.kind !== 'compact' && hive.enabled()) { - hive.send({ to: m.to, act: 'request', subject: m.label, body: m.body }, 'scheduler'); + const doSend = (body: string): void => { + try { hive.send({ to: m.to, act: 'request', subject: m.label, body }, 'scheduler'); } + catch (e) { console.error('[scheduler] send', m.id, e); } + }; + gate = runStandupTick(m.id, { + readMission: () => (readConfig().missions ?? []).find((x) => x.id === m.id) ?? m, + collect: collectFloorState, + now: Date.now, + send: () => { + if (wantsUsage(m)) void missionBodyWithUsage(m).then(doSend); + else doSend(m.body); + }, + recordSkip: (rec) => appendStandupSkip(rec), + stamp: (patch) => { + const current = readConfig().missions ?? []; + writeConfig({ + missions: current.map((x) => (x.id === m.id ? { ...x, ...patch } : x)) + }); + } + }, forced); } // Auto-compact: do NOT jam /compact into busy terminals. Hand it to the // renderer, which queues a /compact per agent (deduped — never two at // once) and delivers it only when that agent goes idle (its drain loop), // so a working agent compacts between steps, never mid-step. - // - // The CADENCE now belongs to the context trigger, not to a mission — but - // the legacy per-mission `autoCompact` flag keeps working, routed through - // the same emit so there is exactly ONE path from main to the renderer. - // It carries the context trigger's current rule so a mission-driven - // compaction obeys the same pressure thresholds as a trigger-driven one. if (m.autoCompact || m.kind === 'compact') { emitContextTrigger('compact', contextRule('compact')); } - const current = readConfig().missions ?? []; - const next = current.map((x) => - x.id === m.id ? { ...x, lastFiredAt: Date.now() } : x - ); - writeConfig({ missions: next }); + if (!gate) { + const firedAt = Date.now(); + const current = readConfig().missions ?? []; + writeConfig({ + missions: current.map((x) => (x.id === m.id ? { ...x, lastFiredAt: firedAt } : x)) + }); + } // Let the SCHEDULES panel refresh its "last fired" without a reload (#2.3). try { liveWebContents()?.send('missions:updated'); } catch { /* window gone */ } } catch (e) { @@ -897,10 +1030,10 @@ function syncContextTriggers(): void { * entry is therefore a stale carry-over from a prior session that quit/crashed * WITHOUT archiving (e.g. the pre-acc13a3 'assistant' Dwight entry). Left as-is * they have no live PTY, so the breaker beat steers them and the steer bounces to - * GOD as a requires_reply GOD can't clear → inbox flood. + * ORCHESTRATOR as a requires_reply ORCHESTRATOR can't clear → inbox flood. * * "No live PTY" = ptyForAgent(id) === undefined (ptyToAgent is populated only at - * spawn and pruned on teardown). God is never archived. A user's real agents are + * spawn and pruned on teardown). Orchestrator is never archived. A user's real agents are * unaffected: the "restore team" flow respawns them through ensureAgent, which * re-clears `archived` — restorability does not depend on the archived flag. */ function archiveOrphanedAgents(): void { @@ -909,7 +1042,7 @@ function archiveOrphanedAgents(): void { const reg = hive.registry(); for (const [id, a] of Object.entries(reg.agents)) { if (a.archived) continue; - if (id === reg.godId) continue; // god is never archived + if (id === reg.orchestratorId) continue; // orchestrator is never archived if (ptyForAgent(id)) continue; // has a live PTY → genuinely active hive.setArchived(id, true); // stale archived:false orphan → archive console.log('[migration] archived orphaned agent (no live PTY):', id); @@ -1116,7 +1249,7 @@ function looksStuck(windowMs: number): boolean { const reg = hive.registry(); const now = Date.now(); for (const [id, a] of Object.entries(reg.agents)) { - if (a.archived || id === reg.godId) continue; + if (a.archived || id === reg.orchestratorId) continue; const ptyId = ptyForAgent(id); if (!ptyId) continue; const idle = ptyManager.idleFor(ptyId) ?? Infinity; @@ -1125,18 +1258,18 @@ function looksStuck(windowMs: number): boolean { return false; } -/** Bounded digest for god — paths + counts, never full files (reference-passing, +/** Bounded digest for orchestrator — paths + counts, never full files (reference-passing, * #6.2). A few hundred tokens at most. */ function buildHeartbeatDigest(quietMs: number, actionable = 0): string { const reg = hive.registry(); - const active = Object.entries(reg.agents).filter(([id, a]) => !a.archived && id !== reg.godId); + const active = Object.entries(reg.agents).filter(([id, a]) => !a.archived && id !== reg.orchestratorId); const names = active.map(([, a]) => a.name).join(', ') || '—'; const boardHead = hive.board().split('\n').slice(0, 10).join('\n').trim(); const log = hive.logTail(8).map((e) => { try { return JSON.stringify(e); } catch { return ''; } }).filter(Boolean).join('\n'); const withInbox = active.filter(([id]) => hive.inbox(id).length > 0).map(([, a]) => a.name); // When real agent/human mail is waiting, lead with an explicit call-to-action // instead of the "quiet" line — this beat fired BECAUSE of unread actionable - // inbox, not because the floor went quiet, and god must read it now. + // inbox, not because the floor went quiet, and orchestrator must read it now. const header = actionable > 0 ? `Floor heartbeat — ${actionable} actionable inbox message(s) awaiting you (worker/human mail). Drain your inbox NOW and act on them.` : `Floor heartbeat — quiet ~${Math.round(quietMs / 60000)}m.`; @@ -1157,31 +1290,31 @@ function buildHeartbeatDigest(quietMs: number, actionable = 0): string { /** Senders whose mail is the scheduler's OWN noise (heartbeat beats, ops-standup * via 'scheduler', breaker steers, generic 'system') — never a reason to wake - * god. Everything else (a worker agent id, 'webhook', a human reply) is real - * mail god must act on. Kept narrow so any future real sender counts by default. */ + * orchestrator. Everything else (a worker agent id, 'webhook', a human reply) is real + * mail orchestrator must act on. Kept narrow so any future real sender counts by default. */ const SYSTEM_SENDERS = new Set(['heartbeat', 'scheduler', 'breaker', 'system']); -/** Count of UNREAD actionable messages in god's inbox — real agent/human mail, +/** Count of UNREAD actionable messages in orchestrator's inbox — real agent/human mail, * excluding the scheduler's own beats. Drives an inbox-aware re-engage so a * worker's reply (or a human answer) doesn't sit unread while the floor is busy: * the floor-quiet gate alone misses that case — any active agent keeps the floor - * "loud", so god was never re-engaged until everything else went idle. */ -function godActionableInboxCount(): number { + * "loud", so orchestrator was never re-engaged until everything else went idle. */ +function orchestratorActionableInboxCount(): number { try { - const godId = hive.registry().godId; - if (!godId) return 0; - return hive.inbox(godId).filter((m) => !SYSTEM_SENDERS.has(m.from)).length; + const orchestratorId = hive.registry().orchestratorId; + if (!orchestratorId) return 0; + return hive.inbox(orchestratorId).filter((m) => !SYSTEM_SENDERS.has(m.from)).length; } catch { return 0; } } -/** Re-engage a quiet floor: drop a durable digest into god's inbox. We never - * type directly into god's PTY here — if he's busy that would jam mid-step. The +/** Re-engage a quiet floor: drop a durable digest into orchestrator's inbox. We never + * type directly into orchestrator's PTY here — if he's busy that would jam mid-step. The * inbox message is delivered by the renderer's busy-aware inbox-wake (it nudges - * god to read his inbox only once he's idle), so the heartbeat defers around a - * working god instead of interrupting him. */ -function reengageGod(digest: string): void { + * orchestrator to read his inbox only once he's idle), so the heartbeat defers around a + * working orchestrator instead of interrupting him. */ +function reengageOrchestrator(digest: string): void { if (!hive.enabled()) return; - hive.send({ to: 'god', act: 'request', subject: 'Heartbeat', body: digest }, 'heartbeat'); + hive.send({ to: 'orchestrator', act: 'request', subject: 'Heartbeat', body: digest }, 'heartbeat'); } /** A native toast for breaker constrain/stop, gated on the notifications setting. */ @@ -1194,8 +1327,8 @@ function breakerToast(title: string, body: string): void { /** One circuit-breaker beat: pull a fresh usage sample per active agent, append * it to the durable cost ledger (the SOLE durable cost store), tick the breaker, * emit each BreakerState on control:breakerState (Seam 2), and enforce any - * escalation. God is in the LEDGER (cost visibility) but NOT the breaker inputs - * (the heartbeat manages god; we never auto-steer/kill the orchestrator). */ + * escalation. Orchestrator is in the LEDGER (cost visibility) but NOT the breaker inputs + * (the heartbeat manages orchestrator; we never auto-steer/kill the orchestrator). */ function runBreakerBeat(progressWindowMs: number): void { if (!hive.enabled()) return; const reg = hive.registry(); @@ -1206,13 +1339,13 @@ function runBreakerBeat(progressWindowMs: number): void { // #57/#58: skip assistant + orphaned shells. The breaker must only evaluate // live, real agents. An assistant entry (e.g. the pre-acc13a3 headless // 'Dwight') or any orphaned entry left archived:false with NO live PTY would - // otherwise be steered, and that steer bounces to GOD as a requires_reply GOD + // otherwise be steered, and that steer bounces to ORCHESTRATOR as a requires_reply ORCHESTRATOR // can't clear → inbox flood. ptyForAgent(id) === undefined means no live PTY. - // God is exempt from this orphan check (it keeps its own flow + the godId skip + // Orchestrator is exempt from this orphan check (it keeps its own flow + the orchestratorId skip // below) so its ledger row is unaffected. Live real agents always own a PTY // (ptyToAgent is set at spawn), so their breaker behavior is unchanged. if (a.isAssistant) continue; - if (id !== reg.godId && !ptyForAgent(id)) continue; + if (id !== reg.orchestratorId && !ptyForAgent(id)) continue; const sample = usageProvider.getAgentUsage(id); // #56: only append a ledger row for a LIVE session sample. A dead/orphaned // agent with a frozen transcript still yields a sample via the transcript @@ -1227,7 +1360,7 @@ function runBreakerBeat(progressWindowMs: number): void { // change". Short-circuits before the gate for everyone else, leaving the // live-OTel path exactly as it was. const moved = a.provider !== 'grok' || grokLedgerGate.admits(sample); - if (moved) hive.appendCostLedger(sample); // ledger covers everyone incl. god + if (moved) hive.appendCostLedger(sample); // ledger covers everyone incl. orchestrator } // Second source for the resume key. recordSession() is otherwise reachable // ONLY from the hook shim, so any window where hooks don't land leaves the @@ -1237,7 +1370,7 @@ function runBreakerBeat(progressWindowMs: number): void { // same liveness gate; recordSession writes only on change, so this is a // no-op once the hooks are flowing. if (sample?.sessionId) hive.recordSession(id, sample.sessionId); - if (id === reg.godId) continue; // breaker skips god + if (id === reg.orchestratorId) continue; // breaker skips orchestrator // Progress = fresh coordination files OR a recent OTel tool span. The span // leg closes the background-work blind spot: subagent/Workflow tool calls // never reach the parent session's PostToolUse hook (so the breaker's own @@ -1263,10 +1396,10 @@ function runBreakerBeat(progressWindowMs: number): void { const reason = d.state.reason; if (d.action === 'steer') { hive.send({ to: d.state.agentId, act: 'request', subject: 'Circuit breaker: steer', - body: `Automated guardrail: ${reason}. Re-check your approach — if you're looping or stuck, STOP repeating, summarize what you've tried, and ask god for direction.` }, 'breaker'); + body: `Automated guardrail: ${reason}. Re-check your approach — if you're looping or stuck, STOP repeating, summarize what you've tried, and ask orchestrator for direction.` }, 'breaker'); } else if (d.action === 'constrain') { hive.send({ to: d.state.agentId, act: 'request', subject: 'Circuit breaker: constrain', - body: `Automated guardrail escalated: ${reason}. Stop active work now: switch to read-only/plan, write a short plan of your next step, and send it to god for sign-off BEFORE running more tools.` }, 'breaker'); + body: `Automated guardrail escalated: ${reason}. Stop active work now: switch to read-only/plan, write a short plan of your next step, and send it to orchestrator for sign-off BEFORE running more tools.` }, 'breaker'); breakerToast(`${name} constrained`, reason); } else if (d.action === 'stop') { const ptyId = ptyForAgent(d.state.agentId); @@ -1281,7 +1414,7 @@ function runBreakerBeat(progressWindowMs: number): void { * it cannot answer "what has this agent cost us". See costLifetime.ts. */ const costTotals = new CostLedgerTotals(); -/** Build + write the live fleet snapshot Michael reads (`/fleet.json`). +/** Build + write the live fleet snapshot the orchestrator reads (`/fleet.json`). * Always-on (independent of the heartbeat) since `claude agents` can't see the * hive's sibling sessions. PII-free; never throws (called from a timer). */ function writeFleetSnapshot(): void { @@ -1299,7 +1432,8 @@ function writeFleetSnapshot(): void { .map(([id, a]) => { const u = usageById.get(id); const spans = snap.spans[id] ?? []; - const tokens = u ? u.input + u.output + u.cacheRead + u.cacheCreation : 0; + const tokens = u ? billedEquivalentTokens(u) : 0; + const tokensRaw = u ? rawTokens(u) : 0; // `usd` is LIFETIME (reset-corrected). Until the first fold completes we // fall back to the session figure rather than publishing a cold $0. const lifetime = costTotals.usdFor(id); @@ -1307,11 +1441,12 @@ function writeFleetSnapshot(): void { return { id, name: a.name, - role: a.role ?? (a.isGod ? 'orchestrator' : 'agent'), + role: a.role ?? (a.isOrchestrator ? 'orchestrator' : 'agent'), cwd: a.cwd, - isGod: !!a.isGod, + isOrchestrator: !!a.isOrchestrator, breaker: breaker.levelFor(id), tokens, + tokensRaw, usd: lifetime === null ? sessionUsd : Number(lifetime.toFixed(4)), sessionUsd, lastTool: spans.length ? spans[spans.length - 1].tool : null, @@ -1320,9 +1455,10 @@ function writeFleetSnapshot(): void { onHold: !!a.onHold }; }); - // `hooks` is the control plane's own health (#277): god and the operator + // `hooks` is the control plane's own health (#277): orchestrator and the operator // can read from fleet.json whether hooks are being enforced at all. - hive.writeFleetSnapshot({ ts: now, agents, hooks: hookServer.health() }); + // HEAVY-JOB-SERIALIZE: who holds the heavy-job slots (the orchestrator reads fleet.json every standup). + hive.writeFleetSnapshot({ ts: now, agents, hooks: hookServer.health(), heavyLock: { limit: heavyLimit(readConfig().heavyJobsAtOnce), holders: heavyLock.snapshot() } }); } catch (e) { console.error('[fleet] snapshot failed:', e); } @@ -1340,12 +1476,12 @@ function armHeartbeat(m: ScheduledMission): void { let next = base; try { // (the breaker beat + cost ledger now run on their own always-on timer) - // Re-engage god when the floor is quiet OR when real agent/human mail is - // waiting in god's inbox — the latter is independent of floor-quiet so a + // Re-engage orchestrator when the floor is quiet OR when real agent/human mail is + // waiting in orchestrator's inbox — the latter is independent of floor-quiet so a // worker's reply doesn't sit unread while other agents keep the floor busy. - const actionable = godActionableInboxCount(); + const actionable = orchestratorActionableInboxCount(); if (isFloorQuiet(quiet) || actionable > 0) { - reengageGod(buildHeartbeatDigest(quiet, actionable)); + reengageOrchestrator(buildHeartbeatDigest(quiet, actionable)); next = Math.round(base * 2.5); // back off after re-engaging } else if (looksStuck(quiet)) { next = Math.max(30_000, Math.round(base / 4)); // tighten when an agent is wedged @@ -1380,7 +1516,7 @@ function liveWebContents(): Electron.WebContents | null { return null; } -// ─── Slack webhook server (Slack message → Michael's queue) ────────────────── +// ─── Slack webhook server (Slack message → the orchestrator's queue) ────────────────── /** The running Slack ingestion server, or null when disabled/stopped. */ let slackServer: SlackWebhookServer | null = null; /** The loopback-only reply endpoint (lets the bundled helper post back to Slack @@ -1392,11 +1528,11 @@ let lastSlackUrl: string | undefined; /** AUTONOMOUS REQUEST PROTOCOL — built PER MESSAGE (not a static const) so it can * embed the request's concrete `channel`, `thread_ts`, and the resolved helper - * path. Prepended (server-side, authoritatively) to the working instruction god + * path. Prepended (server-side, authoritatively) to the working instruction orchestrator * reads for any Slack-origin request: there is no interactive human at the - * keyboard, so god must route fast, delegate WITH the exact reply command (so the + * keyboard, so orchestrator must route fast, delegate WITH the exact reply command (so the * worker posts its real result back into THIS thread itself), stay autonomous, - * and only block on enumerated high-severity actions. Prepended to god's PROMPT + * and only block on enumerated high-severity actions. Prepended to orchestrator's PROMPT * only — the human-facing kanban card TITLE stays the user's raw text (the * renderer keeps them split). Trailing space is intentional so the user's message * reads naturally after it. */ @@ -1406,7 +1542,7 @@ function buildAutonomousRequestProtocol(channel: string, threadTs: string, helpe 2. DELEGATE WITH THE REPLY HANDLE — tell that agent to do the work autonomously AND to post its result back to THIS Slack thread itself when done, using exactly: "${hive.nodeCommand()}" "${helperPath}" --channel ${channel} --thread ${threadTs} --text "" (that first path is the harness's bundled Node, already resolved for this machine — pass it verbatim; bare "node" is not on the hook/agent PATH on many machines.) 3. AUTONOMOUS EXECUTION — no interactive questions. PAUSE/ask ONLY for high-severity actions: pushing to main or any remote; buying or spawning infrastructure or paid services; deleting an existing repo, file, or folder it did not create. Stay READ-ONLY at critical infrastructure and git-push-type changes unless explicitly approved. 4. DIRECT, SUBSTANTIVE REPLY — the agent posts a real Slack-mrkdwn answer (short *bold* headline + the actual outcome/specifics/links), NEVER a bare "done"/":white_check_mark:". -5. REPORT TO GOD — the agent then tells you (Michael) what it did. +5. REPORT TO ORCHESTRATOR — the agent then tells you what it did. 6. ASYNC QUESTIONS — if a decision is genuinely needed, don't block: post the question + numbered OPTIONS to the thread via that reply command, and record {q, options, askedAt (ISO + day & time), thread_ts ${threadTs}} so the threaded human reply correlates back and resumes. The user's message starts now: `; } @@ -1705,10 +1841,10 @@ async function startSlackServer(): Promise<{ ok: boolean; url?: string; error?: ); // `text` stays the user's RAW Slack text → drives the readable kanban card // title. `autonomyPreamble` is the authoritative policy block the renderer - // prepends ONLY to god's working instruction (his PTY prompt), keeping the + // prepends ONLY to orchestrator's working instruction (his PTY prompt), keeping the // card title human-facing-clean. Built PER MESSAGE so the AUTONOMOUS REQUEST // PROTOCOL carries THIS request's concrete channel, thread_ts, and the - // resolved helper path — god hands the worker an exact reply command. + // resolved helper path — orchestrator hands the worker an exact reply command. // Server-side so it applies to every session. const ipcMsg: { text: string; channel: string; ts: string; thread_ts: string; autonomyPreamble: string; files?: typeof localFiles } = { text: m.text, channel: m.channel, ts: m.ts, thread_ts: m.thread_ts, @@ -1853,15 +1989,15 @@ function notifyTriggerHistoryUpdated(): void { } /** - * Create the stamped kanban card for an inbound message and route it to god. + * Create the stamped kanban card for an inbound message and route it to orchestrator. * * Split out of `handleWebhookMessage` because the APPROVAL path takes exactly * this route later — an operator saying yes must produce the same card and the - * same god request an auto-allowed message would have, or the two paths drift + * same orchestrator request an auto-allowed message would have, or the two paths drift * and "approved" quietly means something weaker than "allowed". * * Returns false only when the card — the thing the caller polls — could not be - * written. The god routing is best-effort: the card already exists and is + * written. The orchestrator routing is best-effort: the card already exists and is * pollable even if the send hiccups. */ function dispatchWebhookWork(arg: { @@ -1870,7 +2006,7 @@ function dispatchWebhookWork(arg: { message: string; /** Stamped onto the card so a GET can match the caller's token. */ tokenHash?: string; - /** 'webhook' | 'org' — only for the subject line and the god-facing note. */ + /** 'webhook' | 'org' — only for the subject line and the orchestrator-facing note. */ origin: 'webhook' | 'org'; }): boolean { try { @@ -1885,7 +2021,7 @@ function dispatchWebhookWork(arg: { ...(arg.tokenHash ? { webhook: { tokenHash: arg.tokenHash } } : {}) }; // addTask appends against the latest on-disk ledger and is idempotent by task - // id, so a concurrent card writer (Slack, god, voice, another webhook) can't + // id, so a concurrent card writer (Slack, orchestrator, voice, another webhook) can't // have its card lost to our stale whole-ledger overwrite. (writeTasks(...existing) // recreated exactly that race.) A fresh taskId never collides, so this always adds. hive.addTask(card); @@ -1898,14 +2034,14 @@ function dispatchWebhookWork(arg: { // never the raw token. try { hive.send({ - to: 'god', + to: 'orchestrator', act: 'request', subject: `[${arg.origin}] ${arg.title}`, body: `${arg.message}\n\n(Inbound via the generic ${arg.origin} API, tracked as kanban card ${arg.taskId}. When this work is finished, set that card's status to 'done' and fill its 'result' so the caller's status check reflects the outcome.)`, requires_reply: false }, 'webhook'); } catch (e) { - console.error('[webhook] could not route to god:', e instanceof Error ? e.message : e); + console.error('[webhook] could not route to orchestrator:', e instanceof Error ? e.message : e); } return true; } @@ -1914,7 +2050,7 @@ function dispatchWebhookWork(arg: { * A verified POST, run through the endpoint's TriggerMode. * * `isAutoAllowed(mode, kind)` is the whole gate. When it says yes this behaves - * exactly as the single-endpoint server always did — card, god request, capability + * exactly as the single-endpoint server always did — card, orchestrator request, capability * token. When it says no NOTHING reaches the hive: the message is written to the * ledger as `pending` and sits there until the operator decides, and the caller * is handed its token plus a 202 so it can watch the hold rather than believe @@ -2313,12 +2449,13 @@ function createWindow(opts: { floor?: boolean } = {}): BrowserWindow { const geom = cascade ?? saved; const win = new BrowserWindow({ + icon: join(app.isPackaged ? process.resourcesPath : app.getAppPath(), app.isPackaged ? 'agent-stage.png' : 'build/agent-stage.png'), width: geom?.width ?? DEFAULT_WIN.width, height: geom?.height ?? DEFAULT_WIN.height, ...(geom && geom.x !== undefined && geom.y !== undefined ? { x: geom.x, y: geom.y } : {}), minWidth: MIN_WIN.width, minHeight: MIN_WIN.height, - title: isFloor ? 'Munder Difflin — Floor' : 'Munder Difflin', + title: isFloor ? 'Agent Stage — Floor' : 'Agent Stage', backgroundColor: '#FFF8E7', titleBarStyle: 'hiddenInset', show: false, @@ -2354,7 +2491,7 @@ function createWindow(opts: { floor?: boolean } = {}): BrowserWindow { // Permission gate for the renderer (our own trusted, local content). The only // permission we constrain is microphone capture: it's allowed ONLY while a mic // feature is actually live — Free Flow dictation (`freeflowEnabled`) OR a - // Realtime Michael voice session (`realtimeVoiceEnabled`, flipped on by the + // Realtime voice voice session (`realtimeVoiceEnabled`, flipped on by the // session at start() before getUserMedia, off at stop()). With both flags off, // there's zero mic access even at the Electron layer. We deliberately do NOT // gate on OpenAI-key presence: that key (`apikey:openai`) is shared with the CLI @@ -2594,7 +2731,7 @@ function findCodexHomeForSession(sessionId: string, siblingsRoot: string): strin } } -/** Spawn options shared by the `pty:spawn` IPC handler and the god-triggered +/** Spawn options shared by the `pty:spawn` IPC handler and the orchestrator-triggered * ephemeral-worker watcher. */ type AgentSpawnOptions = SpawnOptions & { hive?: AgentMeta; isolate?: boolean; resume?: boolean; requireResume?: boolean; resumeSessionId?: string; provider?: AgentProvider; noAutoInstall?: boolean }; @@ -2621,13 +2758,13 @@ ipcMain.handle('pty:spawn', async (evt, opts: AgentSpawnOptions) => { /** Core agent-spawn logic — provider inference, the missing-CLI installer * short-circuit, git-worktree isolation, hive provisioning, model/resume flags, * and the final PTY spawn. Extracted VERBATIM from the `pty:spawn` IPC handler so - * it can ALSO be invoked by the god-triggered ephemeral-worker watcher (which has + * it can ALSO be invoked by the orchestrator-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 }> { // ── 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 + // the orchestrator-triggered ephemeral-worker watcher), so it is where a user-typed // `~/dev/foo` becomes an absolute path. Only a shell expands `~`; Node treats it // as a literal dir, so without this every downstream existsSync/statSync fails // with `cwd does not exist`. Expanding BEFORE hive provisioning is what makes the @@ -2816,7 +2953,7 @@ async function spawnAgentCore(opts: AgentSpawnOptions, owner: Electron.WebConten // silently fell back to a fresh session — returned so the dialog can surface it. let resumeNotFound = false; // Set when `--resume` was actually attached (explicit id or restore-on-restart), - // so the renderer can skip re-orienting a god/assistant that resumed its thread. + // so the renderer can skip re-orienting a orchestrator/assistant that resumed its thread. let didResume = false; // Claude-only — these are Claude Code flags; other CLIs carry their own flags // in the command string the renderer already built. @@ -2836,17 +2973,15 @@ async function spawnAgentCore(opts: AgentSpawnOptions, owner: Electron.WebConten // cross-session message to it came back "held for the recipient user's // approval" with no surface for anyone to ever grant that approval. const args = argsWithAutoModeFlag(opts.args ?? [], cfg.autoMode, provider); - // Model precedence: an explicit per-agent --model (from the renderer) wins; - // else the user's global defaultModel; else the role-based default tier. The - // GOD is special-cased: it has its own engine config (godProvider/godModel), so - // modelForRole resolves it and that wins over the worker-oriented defaultModel. + // Model precedence: an explicit renderer --model wins; otherwise the model + // recorded from this agent's Claude status line wins over app-wide defaults. + // This keeps separate agents' `/model` choices out of Claude's shared global + // settings file while retaining the existing orchestrator/worker fallback behavior. if (!args.includes('--model')) { - const m = opts.hive.isGod - ? modelForRole(opts.hive, cfg) - : cfg.defaultModel ?? modelForRole(opts.hive, cfg); + const m = modelForHiveSpawn(opts.hive, cfg, hive.lastModel(opts.hive.id)); if (m) args.push('--model', m); } - // Name the Remote Control session after the agent (Michael, Jim, Dev1…) so it + // Name the Remote Control session after the agent (the orchestrator, Jim, Dev1…) so it // is identifiable in claude.ai / the mobile app. Otherwise Claude defaults the // prefix to the machine hostname (e.g. "vyapaks-macbook-pro-…"), which is // opaque when several agents run at once — especially with remoteControlAtStartup @@ -2869,7 +3004,33 @@ async function spawnAgentCore(opts: AgentSpawnOptions, owner: Electron.WebConten // actually present (already or after the copy); otherwise fall back to a fresh // session rather than launching a `--resume` against a missing id. const explicitSid = typeof opts.resumeSessionId === 'string' ? opts.resumeSessionId.trim() : ''; - const sid = explicitSid || (opts.resume === true ? hive.lastSession(opts.hive.id) : undefined); + let sid = explicitSid || (opts.resume === true ? hive.lastSession(opts.hive.id) : undefined); + + // GOD-STARTUP-TOKENS R1 (godStartup.ts): an AUTOMATIC orchestrator resume whose last request was too + // big (>150k), is past the cache lifetime (1h), or was made by another Claude Code version or model + // starts FRESH with a handoff instead (hive.armGodHandoff; the hooks put it in context). + const godCurrent = opts.hive.isOrchestrator && !explicitSid && sid + ? { + cliVersion: readClaudeVersion(await ptyManager.commandPath(opts.command.trim().split(/\s+/)[0] || opts.command)), + model: args.find((_, i, arr) => arr[i - 1] === '--model') ?? null + } + : null; + const godAgentId = opts.hive.id; + const godCwd = opts.cwd; + const godFresh = (s: string): GodFreshReason[] | null => !godCurrent ? null : godFreshStart(s, { + agentId: godAgentId, + current: godCurrent, + transcriptPath: (id) => sessionTranscriptPath(godCwd, id), + now: Date.now, + log: (row) => hive.appendLog(row), + retire: (id) => hive.retireSession(godAgentId, id), + arm: (h) => (hive as any).armGodHandoff(godAgentId, h) + }); + if (sid && !explicitSid && godFresh(sid)) { + console.log(`[resume] ${opts.hive.id}: session ${sid} would re-send its whole context; starting fresh with a handoff`); + sid = undefined; + } + if (sid && !args.includes('--resume')) { if (seedSessionTranscript(opts.cwd, sid)) { args.push('--resume', sid); @@ -3224,11 +3385,15 @@ ipcMain.handle('config:update', (_evt, patch: Partial) => { // the launch → first-agent funnel. `provider` is the engine chosen in the wizard. // Fired here (main), not in the renderer, so it rides the same allowlist as the rest. if (!wasOnboarded && next.onboardingComplete) { - analytics.track('onboarding_completed', { provider: next.godProvider ?? 'claude' }); + analytics.track('onboarding_completed', { provider: next.orchestratorProvider ?? 'claude' }); } // Keep the hive's mirror of the spawn gate current. The queue itself reads // config per tick so it gates immediately; this is for the PROMPT, which is - // built per spawn, so flipping the toggle reaches god the next time he starts. + // built per spawn, so flipping the toggle reaches orchestrator the next time he starts. + if (typeof patch?.themesDir === 'string') setConfiguredThemesDir(patch.themesDir); + if (typeof patch?.officeTheme === 'string' || typeof patch?.themesDir === 'string') { + setThemeOrchestratorName(readThemeOrchestratorName(readConfig().officeTheme)); + } if (typeof patch?.orchestratorMaySpawn === 'boolean') hive.setOrchestratorMaySpawn(patch.orchestratorMaySpawn); if (!hiveWasEnabled && hive.enabled()) { console.log('[hive] harnessHome configured — bootstrapping hive services'); @@ -3287,6 +3452,8 @@ ipcMain.handle('config:changeHome', async (_evt, payload: unknown) => { try { stopSlackServer(); } catch (e) { console.error('[changeHome] slack.stop:', e); } try { stopWebhookServer(); } catch (e) { console.error('[changeHome] webhook.stop:', e); } try { memory.stop(); } catch (e) { console.error('[changeHome] memory.stop:', e); } + // Close the hive's kept-open log and ledger before the copy (and the relaunch). + try { hive.closeAppendFiles(); } catch (e) { console.error('[changeHome] hive.closeAppendFiles:', e); } try { reflector.stop(); } catch (e) { console.error('[changeHome] reflector.stop:', e); } if (mode === 'move' && oldHome) { @@ -3355,6 +3522,58 @@ ipcMain.handle('fs:statAbs', (_evt, p: unknown) => { return statAbs(p); }); +// ─── IPC: themes (scanned set only, §2.5 - §2.7) ──────────────────────────── +ipcMain.handle('themes:list', () => { + return scanThemes().packs; +}); + +// Rescan after the user drops a pack into a themes folder (or fixes one). +ipcMain.handle('themes:rescan', () => scanThemes(true).packs); + +ipcMain.handle('themes:load', (_evt, id: unknown) => { + if (typeof id !== 'string') return null; + return readPack(id); +}); + +ipcMain.handle('themes:validate', (_evt, id: unknown) => { + if (typeof id !== 'string') { + return { + valid: false, + issues: [{ level: 'error', code: 'bad-arg', path: '', message: 'id must be a string' }] + }; + } + return validateThemePack(id); +}); + +// Settings "Test" button: run the endpoint with the (possibly unsaved) form values +// and report what the orchestrator would see. Never writes config or the hive. +ipcMain.handle('usage:test', async (_evt, raw: unknown) => { + const cfg = normalizeUsageEndpoint({ ...(raw as object), enabled: true }); + if (!cfg.enabled) return { ok: false, error: 'enter an http(s) URL first' }; + const res = await fetchUsage(cfg); + return res.ok ? { ok: true, summary: summarizeForOrchestrator(res.snapshot) } : res; +}); + +// The renderer asks before it registers the orchestrator, so a theme's suggested +// name is in place the first time the name is persisted. +ipcMain.handle('themes:orchestratorName', () => { + setThemeOrchestratorName(readThemeOrchestratorName(readConfig().officeTheme)); + return getThemeOrchestratorName(); +}); + +ipcMain.handle('themes:openFolder', async () => { + const userThemesDir = join(app.getPath('userData'), 'themes'); + try { + if (!existsSync(userThemesDir)) { + mkdirSync(userThemesDir, { recursive: true }); + } + const err = await shell.openPath(userThemesDir); + return { ok: !err, error: err || undefined }; + } catch (e) { + return { ok: false, error: (e as Error).message }; + } +}); + /** Reveal a path in the OS file browser — Finder, Explorer, or whatever the * Linux desktop registers. Backs ⌘-click on a terminal path we cannot open * ourselves (an image, an archive, an unknown extension). @@ -3550,14 +3769,6 @@ ipcMain.handle('hive:patchAgentRole', (_evt, id: unknown, role: unknown) => { ipcMain.handle('hero:payload', async (_evt, force: unknown) => loadHero(join(app.getPath('userData'), 'hero.json'), { force: force === true })); -// ─── IPC: model catalog (remote data, cached) ─────────────────────────────── -/** The agent model presets, fetched from docs/model-catalog.json on main so a - * new model reaches installed copies without a release. Validated in - * shared/modelCatalogPayload; a null catalog means "keep the baked one". */ -const MODEL_CATALOG_CACHE = () => join(app.getPath('userData'), 'model-catalog.json'); -ipcMain.handle('models:catalog', async (_evt, force: unknown) => - loadModelCatalog(MODEL_CATALOG_CACHE(), { force: force === true })); - // ─── IPC: skills (installed locally, and the browsable catalog) ───────────── /** Skills the CLIs on this machine can already use. Scans the registered repos * plus the agent's own cwd, so a project-scoped skill shows up where it applies. */ @@ -3625,6 +3836,33 @@ ipcMain.handle('skills:reveal', (_evt, path: unknown) => { * Finder-launched app) and knows whether the palace is initialised, so it is * authoritative and reused rather than re-probed differently here. */ +// REFRESH-MODELS: ONE models file (userData/models.json) that every picker reads. It is filled ONLY +// by Settings -> Agents & Models -> "Refresh models" (models:refresh). models:catalog is a file +// read: nothing is looked up at startup or when a picker opens. Every lookup is async (no sync +// child process on main); Claude uses the Models API only with a stored Anthropic BYOK key. +const providerModels = new ProviderModelStore({ + path: join(app.getPath('userData'), 'models.json'), + now: () => Date.now(), + log: (row) => { try { hive.appendLog(row); } catch { /* best-effort */ } } +}); +ipcMain.handle('models:catalog', () => providerModels.read()); +ipcMain.handle('models:refresh', async () => { + const cli = { + platform: process.platform, env: process.env, exists: existsSync, + exec: (file: string, args: string[], opts: Parameters[2], cb: (err: (Error & { code?: unknown; killed?: boolean }) | null, stdout: string) => void) => + execFile(file, args, opts, (err, stdout) => cb(err, String(stdout ?? ''))) + }; + const fetchJson = async (url: string, headers: Record, timeoutMs: number) => { + const res = await fetch(url, { headers, signal: AbortSignal.timeout(timeoutMs) }); + let body: unknown = null; + try { body = await res.json(); } catch { /* not JSON */ } + return { status: res.status, body }; + }; + const { file, rows } = await providerModels.refresh(defaultAdapters(cli, () => integrations.getSecret(providerKeyRef('anthropic')), fetchJson)); + for (const w of BrowserWindow.getAllWindows()) { try { if (!w.isDestroyed()) w.webContents.send('models:catalogChanged', file); } catch { /* gone */ } } + return { file, rows }; +}); + ipcMain.handle('tools:status', (): ToolStatus[] => { const win = process.platform === 'win32'; const mem = (() => { try { memory.resetBinCache(); return memory.status(); } catch { return null; } })(); @@ -3772,7 +4010,7 @@ ipcMain.handle('history:search', (_evt, query: unknown, limit: unknown) => // ─── IPC: quit confirmation ───────────────────────────────────────────────── /** Tear the harness down and quit. Shared by the hard "kill all & quit" path - * and the closing-time conclusion (after the god confirmed the floor saved). */ + * and the closing-time conclusion (after the orchestrator confirmed the floor saved). */ function teardownAndQuit(): void { allowQuit = true; // Each teardown step is best-effort: a throw here (e.g. a dying child or a @@ -3815,8 +4053,8 @@ ipcMain.handle('window:newFloor', () => { }); // ─── IPC: closing time (graceful, data-loss-free shutdown) ────────────────── -// The third quit-dialog button. The god broadcasts closing time, every worker -// saves its memory and ACKs, the god concludes with CLOSING-TIME-COMPLETE — +// The third quit-dialog button. The orchestrator broadcasts closing time, every worker +// saves its memory and ACKs, the orchestrator concludes with CLOSING-TIME-COMPLETE — // only then does the harness tear down. See closingTime.ts for the protocol. const closingTime = new ClosingTimeController( hive, @@ -3851,8 +4089,10 @@ ipcMain.handle('app:resetAll', () => { try { reflector.stop(); } catch (e) { console.error('[reset] reflector.stop:', e); } try { persist.close(); } catch (e) { console.error('[reset] persist.close:', e); } try { ptyManager.killAll(); } catch (e) { console.error('[reset] killAll:', e); } + // The hive's kept-open log and ledger: an open file makes the rm below fail (ENOTEMPTY). + try { hive.closeAppendFiles(); } catch (e) { console.error('[reset] hive.closeAppendFiles:', e); } try { hive.removeExposedCodexData(); } catch (e) { console.error('[reset] removeExposedCodexData:', e); } - // Erase the hive (Michael's + every agent's memory, inboxes, tasks, board, + // Erase the hive (the orchestrator's + every agent's memory, inboxes, tasks, board, // git history) and the semantic-memory palace. Only these harness-created // subdirs are removed — never the user's whole harnessHome folder. for (const dir of [hive.root(), memory.palacePath()]) { @@ -3891,16 +4131,16 @@ ipcMain.handle('hive:agentContext', (_evt, agentId: unknown) => { }); // A consolidated, NON-SENSITIVE per-agent directory for the voice read-layer -// (Realtime Michael's get_agent_detail / list_agents). One read that joins +// (Realtime voice's get_agent_detail / list_agents). One read that joins // everything the office-floor sidebar + telemetry know per agent: the registry -// record (name/role/provider/cwd/status/archived/isGod/isAssistant/sessionId/ +// record (name/role/provider/cwd/status/archived/isOrchestrator/isAssistant/sessionId/ // cwdValid), live token + breaker + last-tool telemetry, and the current context // window fill. Includes ARCHIVED agents (unlike the heartbeat's fleet.json, which -// is live-only) so Michael can speak to inactive agents — their cwd and memory +// is live-only) so the orchestrator can speak to inactive agents — their cwd and memory // stay reachable. PII-free: no secrets, env, or API keys ever leave main; cost is // carried as tokens (+ a usd field the voice layer deliberately never speaks). ipcMain.handle('hive:agentDirectory', () => { - if (!hive.enabled()) return { godId: null, agents: [] }; + if (!hive.enabled()) return { orchestratorId: null, agents: [] }; const reg = hive.registry(); const snap = telemetry.snapshot(); const usageById = new Map(snap.usage.map((u) => [u.agentId, u])); @@ -3913,14 +4153,14 @@ ipcMain.handle('hive:agentDirectory', () => { return { id, name: a.name, - role: a.role ?? (a.isGod ? 'orchestrator' : 'agent'), + role: a.role ?? (a.isOrchestrator ? 'orchestrator' : 'agent'), provider: a.provider ?? 'claude', model: u?.model ?? null, status: a.status ?? 'idle', cwd: a.cwd ?? null, cwdValid: a.cwdValid ?? null, archived: !!a.archived, - isGod: !!a.isGod, + isOrchestrator: !!a.isOrchestrator, isAssistant: !!a.isAssistant, sessionId: a.sessionId ?? null, hasMemory: hive.hasMemory(id), @@ -3935,7 +4175,7 @@ ipcMain.handle('hive:agentDirectory', () => { contextPct: ctx && ctx.limit > 0 ? Math.round((ctx.tokens / ctx.limit) * 100) : null }; }); - return { godId: reg.godId, agents }; + return { orchestratorId: reg.orchestratorId, agents }; }); // ─── IPC: live telemetry (the OTel collector — the locked usage-provider seam) ─ @@ -4290,7 +4530,7 @@ ipcMain.handle('triggerHistory:clear', (_evt, arg: unknown) => { * The operator's verdict on a held message. * * 'approved' RELEASES it: it takes the identical path an auto-allowed message - * would have taken (card + god request), then the entry flips. 'rejected' just + * would have taken (card + orchestrator request), then the entry flips. 'rejected' just * flips — nothing is ever dispatched. * * Idempotent by construction: only an entry still sitting at `pending` can be @@ -4316,7 +4556,7 @@ ipcMain.handle('triggerHistory:decide', (_evt, arg: unknown) => { const tokenHash = heldTokenHashFor(id); const title = entry.title ?? (entry.body.length > 80 ? `${entry.body.slice(0, 79)}…` : entry.body); if (!dispatchWebhookWork({ taskId, title, message: entry.body, tokenHash, origin: entry.source })) { - // The card is what the caller polls and what god works from. Leave the entry + // The card is what the caller polls and what orchestrator works from. Leave the entry // pending so the operator can approve again once the hive is writable. return entry; } @@ -4427,36 +4667,36 @@ ipcMain.handle('freeflow:transcribe', async (_evt, arg: unknown) => { return out; }); -// ─── IPC: Realtime Michael (voice orchestrator — ephemeral token mint, rt-1) ── +// ─── IPC: Realtime voice (voice orchestrator — ephemeral token mint, rt-1) ── // MAIN owns the BYOK OpenAI key (encrypted broker, apikey:openai) and mints a // short-lived EPHEMERAL client secret; the real key never crosses IPC. All wiring // lives in ./realtime so this stays a single registration line. registerRealtimeIpc(); -// ─── IPC: Realtime Michael voice ACTIONS (rt-5, Phase 2) ───────────────────── -// Thin adapters over the SAME main fns the god PTY already uses. ALL of the safety +// ─── IPC: Realtime voice voice ACTIONS (rt-5, Phase 2) ───────────────────── +// Thin adapters over the SAME main fns the orchestrator PTY already uses. ALL of the safety // spine — soft-vs-destructive tiering, the two-step verbal echo-back confirm, the -// distinct-token rule, the hard allowlist (kill-god / mass-ops forbidden), and the -// michael-voice attribution — lives in ./realtimeActions. This site only injects +// distinct-token rule, the hard allowlist (kill-orchestrator / mass-ops forbidden), and the +// orchestrator-voice attribution — lives in ./realtimeActions. This site only injects // the existing functions; it adds NO new orchestration logic. -// ─── IPC: Realtime Michael completion watcher (rt-12, Phase 2) ─────────────── +// ─── IPC: Realtime voice completion watcher (rt-12, Phase 2) ─────────────── // Jim's net-new engine (realtimeCompletionWatcher.ts) detects a voice-dispatched -// task finishing (card→done OR a done-reply in michael-voice's inbox) and EMITS it; +// task finishing (card→done OR a done-reply in orchestrator-voice's inbox) and EMITS it; // I own the seam — inject the hive read deps, push completions to the live session -// (so Michael speaks them unprompted), and bridge waitFor / queue-drain over IPC. +// (so the orchestrator speaks them unprompted), and bridge waitFor / queue-drain over IPC. const completionWatcher = initCompletionWatcher({ readTasks: () => { const t = hive.tasks() as { tasks?: TaskCard[] }; return Array.isArray(t?.tasks) ? t.tasks : []; }, - // Voice dispatches go out as from:michael-voice, so assignee done-replies land here. + // Voice dispatches go out as from:orchestrator-voice, so assignee done-replies land here. readInbox: () => { - // Voice dispatches go out from:michael-voice, so done-replies normally land in its - // inbox — but an assignee may address god out of habit. Merge both inboxes (de-dupe - // by id) so a god-addressed completion isn't missed; the detector filters by sender. + // Voice dispatches go out from:orchestrator-voice, so done-replies normally land in its + // inbox — but an assignee may address orchestrator out of habit. Merge both inboxes (de-dupe + // by id) so a orchestrator-addressed completion isn't missed; the detector filters by sender. try { - const mv = hive.inbox('michael-voice') as unknown as InboxMessage[]; - const godId = hive.registry().godId; - const god = godId ? (hive.inbox(godId) as unknown as InboxMessage[]) : []; + const mv = hive.inbox('orchestrator-voice') as unknown as InboxMessage[]; + const orchestratorId = hive.registry().orchestratorId; + const orchestrator = orchestratorId ? (hive.inbox(orchestratorId) as unknown as InboxMessage[]) : []; const seen = new Set(); - return [...mv, ...god].filter((m) => !!m?.id && !seen.has(m.id) && seen.add(m.id) !== undefined); + return [...mv, ...orchestrator].filter((m) => !!m?.id && !seen.has(m.id) && seen.add(m.id) !== undefined); } catch { return []; } @@ -4465,7 +4705,7 @@ const completionWatcher = initCompletionWatcher({ try { if (!Notification.isSupported()) return; const reg = hive.registry(); - const title = resolveGodName(reg.agents[reg.godId ?? 'god']?.name); + const title = resolveOrchestratorName(reg.agents[reg.orchestratorId ?? 'orchestrator']?.name); new Notification({ title, body: evt.summary }).show(); } catch { /* best-effort */ } } @@ -4577,18 +4817,18 @@ ipcMain.handle('realtime:waitFor', (_e, taskId: unknown, timeoutMs: unknown) => : Promise.resolve({ timedOut: true as const, taskId: '' })); completionWatcher.start(); -// ─── god-triggered ephemeral Slack workers ────────────────────────────────── -// god drops a spawn-request JSON into HIVE_ROOT/spawn-requests/; MAIN polls that +// ─── orchestrator-triggered ephemeral Slack workers ────────────────────────────────── +// orchestrator drops a spawn-request JSON into HIVE_ROOT/spawn-requests/; MAIN polls that // queue (same cadence + atomic-rename archival as the hive router — reliability // over latency, no fs.watch/dedup needed), spins up a FRESH ISOLATED worker via // the shared spawnAgentCore, dispatches the objective through the standard inbox // path, then watches each worker for a terminal `act:"done"` (success → release) // or excessive idleness (reap). All teardown flows through teardownPty's // safety-gate, so a worker's worktree is never auto-removed while it holds -// unintegrated work. Every terminal failure informs god WITH the Slack coords so -// god closes the Slack loop; the success path is the worker replying in-thread. +// unintegrated work. Every terminal failure informs orchestrator WITH the Slack coords so +// orchestrator closes the Slack loop; the success path is the worker replying in-thread. -/** A spawn-request god drops into HIVE_ROOT/spawn-requests/.json. god authors +/** A spawn-request orchestrator drops into HIVE_ROOT/spawn-requests/.json. orchestrator authors * these directly; `objective` and `cwd` are the only required fields. */ interface SpawnRequest { id?: string; @@ -4620,7 +4860,7 @@ let workerWatchTimer: ReturnType | null = null; /** Re-entrancy guard so a slow tick (await spawn / git checks) never overlaps. */ let workerTickRunning = false; -/** HIVE_ROOT/spawn-requests — the queue dir god drops requests into. */ +/** HIVE_ROOT/spawn-requests — the queue dir orchestrator drops requests into. */ function spawnRequestsDir(): string | null { const root = hive.root(); return root ? join(root, 'spawn-requests') : null; @@ -4682,8 +4922,8 @@ function workerDoneAt(workerId: string, spawnedAt: number): number | null { } /** Spin up one ephemeral worker from a spawn-request. Terminal failures (bad - * request, missing CLI, spawn error) archive to .failed and inform god WITH the - * Slack coords so god can post a 'couldn't start' reply. On success the worker is + * request, missing CLI, spawn error) archive to .failed and inform orchestrator WITH the + * Slack coords so orchestrator can post a 'couldn't start' reply. On success the worker is * registered (for done-scan / reaping / safe teardown) and dispatched its * objective via the standard inbox path. */ async function processSpawnRequest(filePath: string): Promise { @@ -4692,14 +4932,14 @@ async function processSpawnRequest(filePath: string): Promise { raw = JSON.parse(readFileSync(filePath, 'utf8')) as SpawnRequest; } catch (e) { console.error('[worker] unparseable spawn-request:', filePath, e); - informGod('[worker spawn rejected] unparseable request', `Could not parse spawn-request ${basename(filePath)} — ${String(e)}`); + informOrchestrator('[worker spawn rejected] unparseable request', `Could not parse spawn-request ${basename(filePath)} — ${String(e)}`); archiveRequest(filePath, '.failed'); return; } const slack = raw.slack && typeof raw.slack.channel === 'string' && typeof raw.slack.thread_ts === 'string' ? { channel: raw.slack.channel, thread_ts: raw.slack.thread_ts } : undefined; const fail = (reason: string): void => { - informGod(`[worker spawn rejected] ${reason}`, `Spawn-request ${basename(filePath)} rejected: ${reason}.`, slack); + informOrchestrator(`[worker spawn rejected] ${reason}`, `Spawn-request ${basename(filePath)} rejected: ${reason}.`, slack); archiveRequest(filePath, '.failed'); }; @@ -4739,7 +4979,7 @@ async function processSpawnRequest(filePath: string): Promise { return; } // Missing-CLI → FAIL FAST. A headless worker has no human to watch an installer, - // so we never run the cc49e1e install banner here — we reject and tell god. + // so we never run the cc49e1e install banner here — we reject and tell orchestrator. if (!ptyManager.isCommandAvailable(bin)) { fail(`engine CLI "${bin}" is not installed`); return; } const isolate = raw.isolate !== false; // default true @@ -4779,7 +5019,7 @@ async function processSpawnRequest(filePath: string): Promise { } if (!res.ok) { integrationBroker.revoke(workerId); fail(`spawn failed — ${res.error ?? 'unknown error'}`); return; } - // A god-hired worker is a MAIN-initiated spawn, so the renderer would never + // A orchestrator-hired worker is a MAIN-initiated spawn, so the renderer would never // card it on its own (same reason as the voice-spawn broadcast): without this // the worker is invisible on the floor, never enters the roster, and after a // restart nothing offers to restore it. The card rides the normal agent @@ -4809,14 +5049,14 @@ async function processSpawnRequest(filePath: string): Promise { // Dispatch the objective via the standard inbox path (zero new transport), // reusing the autonomous-request preamble so the worker gets the exact Slack - // reply command + autonomy policy. `from: god` so the worker treats it as a god + // reply command + autonomy policy. `from: orchestrator` so the worker treats it as a orchestrator // dispatch per its protocol. try { const prefix = slack ? buildAutonomousRequestProtocol(slack.channel, slack.thread_ts, slackReplyScriptPath()) : '[AUTONOMOUS WORKER TASK — no interactive human is watching. Work autonomously; do not ask interactive questions.] The task starts now: '; - const suffix = `\n\n[CAPABILITIES] Before you start, consult your capability catalog — run the \`/capabilities\` skill (or read \`$AGENT_DIR/.claude/skills/capabilities/SKILL.md\`). It lists your temporal date-range skills (\`/today\`, \`/last30Days\`, \`/lastQuarter\`, …) and the integrations available to you (reached via the loopback broker) and how to call each. For any time-scoped work, resolve the dates with those skills instead of computing them by hand.\n\n[WORKER COMPLETION] When finished, signal done by sending ONE outbox message to god with "act":"done" and a short result summary — that releases this ephemeral worker (terminal closed; your branch is handed to god). Do NOT push to any remote; god is the sole integrator.`; - hive.send({ to: workerId, conversation: `worker-${reqId}`, act: 'request', subject: meta.name, body: `${prefix}${objective}${suffix}` }, 'god'); + const suffix = `\n\n[CAPABILITIES] Before you start, consult your capability catalog — run the \`/capabilities\` skill (or read \`$AGENT_DIR/.claude/skills/capabilities/SKILL.md\`). It lists your temporal date-range skills (\`/today\`, \`/last30Days\`, \`/lastQuarter\`, …) and the integrations available to you (reached via the loopback broker) and how to call each. For any time-scoped work, resolve the dates with those skills instead of computing them by hand.\n\n[WORKER COMPLETION] When finished, signal done by sending ONE outbox message to orchestrator with "act":"done" and a short result summary — that releases this ephemeral worker (terminal closed; your branch is handed to orchestrator). Do NOT push to any remote; orchestrator is the sole integrator.`; + hive.send({ to: workerId, conversation: `worker-${reqId}`, act: 'request', subject: meta.name, body: `${prefix}${objective}${suffix}` }, 'orchestrator'); } catch (e) { console.error('[worker] dispatch send failed:', e); } @@ -4841,7 +5081,7 @@ let gcSweepRunning = false; /** Reclaim preserved worker worktrees (+ their scratch dirs) whose work is now * integrated, or whose worktree was already removed by hand. Fail-safe: a worktree * is removed ONLY when `worktreeIsGcSafe` proves it clean AND integrated; any doubt - * KEEPS it (never discards un-integrated work — god is the sole integrator). Runs + * KEEPS it (never discards un-integrated work — orchestrator is the sole integrator). Runs * inside the worker tick, throttled to GC_SWEEP_MS, and is a no-op when nothing is * preserved (the common case → zero cost). */ async function gcPreservedWorktrees(): Promise { @@ -4852,7 +5092,7 @@ 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) Worktree already gone (removed at clean teardown, or god removed it by + // (a) Worktree already gone (removed at clean teardown, or orchestrator removed it by // hand per the preserve note) → just reclaim the scratch dir + drop tracking. if (!existsSync(e.wtPath)) { removeWorkerScratch(e.workerId); @@ -4872,7 +5112,7 @@ async function gcPreservedWorktrees(): Promise { removeWorkerScratch(e.workerId); preservedWorktrees.delete(key); console.log(`[worker gc] reclaimed ${e.workerId} (${safe.detail})`); - informGod( + informOrchestrator( `[worker worktree reclaimed] ${e.workerId}`, `The preserved worktree for ${e.workerId} is now integrated (${safe.detail}), so it and its scratch dir were garbage-collected.\nWorktree: ${e.wtPath}`, e.slack @@ -4903,7 +5143,7 @@ async function ephemeralWorkerTick(): Promise { // synchronously, so when node-pty's async onExit later fires it fails the // session-identity guard and the global exit handler (→ teardownPty) // never runs. Relying on onExit here left released workers un-torn-down: - // no hive archive, no hive:agentArchived, frozen floor cards, and god + // no hive archive, no hive:agentArchived, frozen floor cards, and orchestrator // kept mailing dead agents (seen live 2026-08-16 with worker-business/ // worker-qa/worker-bizreview). A double teardown is a harmless no-op. for (const [workerId, rec] of [...liveWorkers]) { @@ -4922,7 +5162,7 @@ async function ephemeralWorkerTick(): Promise { // unanswered for 21h). Only the DONE path settles — an idle/token-cap // reap never signaled completion, so its unread mail stays pending — // and only mail from BEFORE the done signal: a follow-up that crossed - // the worker's done is still nobody's, so it stays pending. God hears + // the worker's done is still nobody's, so it stays pending. Orchestrator hears // which requests/queries were filed unread, so none vanishes silently. const settled = hive.settleInbox(workerId, doneAt); if (settled.moved > 0) console.log(`[worker] ${workerId}: filed ${settled.moved} unread inbox message(s) under inbox/.done`); @@ -4930,13 +5170,13 @@ async function ephemeralWorkerTick(): Promise { // The worker's own work order is dispatched in conversation // `worker-` (processSpawnRequest) and is exactly what it just // completed: reporting it "filed unread" on every release would cost - // god a turn each time for nothing. Only OTHER requests/queries count. + // orchestrator a turn each time for nothing. Only OTHER requests/queries count. // Subjects are agent-written: keep each on its own line. const unanswered = settled.unanswered.filter((m) => m.conversation !== `worker-${rec.reqId}`); if (unanswered.length > 0) { const oneLine = (s: string): string => s.replace(/[\r\n]+/g, ' '); const lines = unanswered.map((m) => `- ${m.act} ${oneLine(m.id)} from ${oneLine(m.from)}: "${oneLine(m.subject)}"`); - informGod( + informOrchestrator( `[worker released — mail filed unread] ${workerId}`, `Worker ${workerId} signaled done and was released. These messages were still unread in its inbox and were filed under inbox/.done without an answer:\n` + lines.join('\n') @@ -4956,7 +5196,7 @@ async function ephemeralWorkerTick(): Promise { if (used > tokenCap) { rec.releasing = true; console.warn(`[worker] reaping ${workerId} — token cap (${used.toLocaleString()} > ${tokenCap.toLocaleString()})`); - informGod( + informOrchestrator( `[worker reaped — token cap] ${workerId}`, `Worker ${workerId} used ${used.toLocaleString()} tokens (> its cap of ${tokenCap.toLocaleString()}) and was reaped. Any committed work on its branch is preserved for you.`, rec.slack @@ -4971,7 +5211,7 @@ async function ephemeralWorkerTick(): Promise { if (idleMs > idleTimeoutMs) { rec.releasing = true; console.warn(`[worker] reaping idle ${workerId} (${Math.round(idleMs / 60000)}min idle)`); - informGod( + informOrchestrator( `[worker reaped — idle] ${workerId}`, `Worker ${workerId} produced no output for ${Math.round(idleMs / 60000)} min (> the ${Math.round(idleTimeoutMs / 60000)} min cap) and never signaled done, so it was reaped. Any committed work on its branch is preserved for you.`, rec.slack @@ -4993,7 +5233,7 @@ async function ephemeralWorkerTick(): Promise { // // Declining also means declining to CONSUME. A request dropped in while // this is off stays in the queue and runs when it is turned on, rather - // than being eaten and failed for a reason god never asked about. + // than being eaten and failed for a reason orchestrator never asked about. const dir = readConfig().orchestratorMaySpawn ? spawnRequestsDir() : null; if (dir && existsSync(dir)) { let files: string[] = []; @@ -5143,6 +5383,8 @@ function bootstrapHiveServices(): void { if (r.ok) console.log('[broker] integration broker listening on', integrationBroker.url()); else console.error('[broker] failed to start:', r.error); }); + setConfiguredThemesDir(readConfig().themesDir); + setThemeOrchestratorName(readThemeOrchestratorName(readConfig().officeTheme)); ensureDefaultMissions(); // one-time: seed the built-in hourly ops standup syncMissions(); // arm recurring auto-dispatch missions now the router is live syncContextTriggers(); // …and the context trigger's own compact/clear cadences @@ -5199,16 +5441,16 @@ function nudgeWorker(ptyId: string, ids: string[] = []): void { * it gathers live-worker facts (PTY quiescence, inbox depth, control flags) and * lets WorkerWakeWatchdog.decide apply the exact renderer guards (idle-only, * post-boot-grace, not paused/halted, no pending HITL, cooldown), then types the - * same nudge the renderer would have. God is never a candidate (its heartbeat + * same nudge the renderer would have. Orchestrator is never a candidate (its heartbeat * path already re-engages it). */ function runWorkerWakeBeat(): void { if (!hive.enabled()) return; const reg = hive.registry(); - if (!reg?.agents || !reg.godId) return; + if (!reg?.agents || !reg.orchestratorId) return; const now = Date.now(); const facts: WorkerWakeFacts[] = []; for (const [agentId, a] of Object.entries(reg.agents)) { - if (agentId === reg.godId || a?.archived) continue; + if (agentId === reg.orchestratorId || a?.archived) continue; const ptyId = ptyForAgent(agentId); if (!ptyId) continue; const snap = control.snapshot(agentId); @@ -5225,7 +5467,7 @@ function runWorkerWakeBeat(): void { const usage = telemetry.getAgentUsage(agentId); facts.push({ agentId, - isGod: agentId === reg.godId, + isOrchestrator: agentId === reg.orchestratorId, ptyId, lastOutputAt: ptyManager.lastOutputAt(ptyId) ?? 0, inboxIds: mail.map((message) => message.id).filter(Boolean), @@ -5271,7 +5513,7 @@ function runWorkerWakeBeat(): void { } /** (Re)arm the always-on beats (decoupled from the optional heartbeat): the live - * fleet snapshot Michael reads (~8s) + the breaker/cost-ledger beat (~30s). + * fleet snapshot the orchestrator reads (~8s) + the breaker/cost-ledger beat (~30s). * Guarded (clear-then-set) so a re-bootstrap (changeHome recovery) OR a * powerMonitor resume can't stack duplicate timers — these are setInterval * handles that freeze during true system sleep and must be re-armed on wake. */ @@ -5349,8 +5591,8 @@ function onSystemResume(reason: string): void { // The hive message router (outbox→inbox drain) is a setInterval that freezes // during true system sleep exactly like the beats above — but it was the one // always-on timer never re-armed on wake. Symptom: after a long sleep the - // scheduler→god path recovered (it injects straight into god's inbox), while - // every agent's outbox silently stopped draining, so god→worker and + // scheduler→orchestrator path recovered (it injects straight into orchestrator's inbox), while + // every agent's outbox silently stopped draining, so orchestrator→worker and // worker↔worker mail piled up undelivered. Re-arm the poll loop (clear-then-set, // idempotent) and immediately drain the backlog that accrued while we were out // instead of waiting for the first post-wake tick. The renderer's idle inbox-wake @@ -5373,7 +5615,10 @@ function onSystemResume(reason: string): void { } app.whenReady().then(() => { - // Realtime Michael mic-gate hygiene (rt-8 / Pam rt-10 nit): the voice session + if (process.platform === 'darwin' && !app.isPackaged) { + app.dock?.setIcon(join(app.getAppPath(), 'build/agent-stage.png')); + } + // Realtime voice 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 // teardown, leaving the flag stuck true so the gate would boot PRE-OPEN with no @@ -5390,12 +5635,6 @@ app.whenReady().then(() => { enabled: readConfig().telemetryEnabled !== false }); - // Warm the model catalog cache before any picker opens. The renderer reads - // the same cache over IPC on load; doing the network hop here means the file - // is already fresh on disk by the time a modal is opened, and a failure is - // silent by construction (the baked catalog is the floor). - void loadModelCatalog(MODEL_CATALOG_CACHE()).catch(() => { /* never fatal */ }); - // A cold-start deep link (Windows/Linux) rides in on OUR argv. const startupHireLink = process.argv.find((a) => a.startsWith('munderdifflin://')); if (startupHireLink) void handleHireLink(startupHireLink); @@ -5503,7 +5742,7 @@ app.on('will-quit', (e) => { if (analyticsFlushed) return; analyticsFlushed = true; e.preventDefault(); - const finish = (): void => app.exit(0); + const finish = (): void => { try { hive.closeAppendFiles(); } catch { /* rows are on disk */ } app.exit(0); }; Promise.race([ analytics.endSession(), new Promise((r) => setTimeout(r, 1200)) diff --git a/src/main/memory.ts b/src/main/memory.ts index d1be74e1d8..a91fd0f2ad 100644 --- a/src/main/memory.ts +++ b/src/main/memory.ts @@ -233,9 +233,16 @@ export class MemoryManager { this.startMineLoop(); } + private activeProcs = new Set(); + stop(): void { this.mineStopped = true; if (this.mineTimer) { clearTimeout(this.mineTimer); this.mineTimer = null; } + for (const pid of this.activeProcs) { + try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ } + ensureKilled(pid, 500); + } + this.activeProcs.clear(); } /** @@ -368,6 +375,7 @@ export class MemoryManager { const proc = spawn(bin, ['mine', agentDir, '--wing', id, '--agent', id], { env: this.childEnv(), stdio: ['ignore', 'ignore', 'pipe'] }); + if (proc.pid) this.activeProcs.add(proc.pid); let err = ''; proc.stderr?.on('data', (d) => { err += d.toString(); }); // Hard ceiling: a wedged mine used to hold its PID forever AND leave @@ -380,6 +388,7 @@ export class MemoryManager { }, MINE_TIMEOUT_MS); timer.unref?.(); proc.on('close', (code) => { + if (proc.pid) this.activeProcs.delete(proc.pid); clearTimeout(timer); if (code !== 0) { console.error(`[memory] mine ${id} exited ${code}: ${err.slice(-300)}`); @@ -387,7 +396,12 @@ export class MemoryManager { } resolve(); }); - proc.on('error', () => { clearTimeout(timer); this.lastMined.delete(id); resolve(); }); + proc.on('error', () => { + if (proc.pid) this.activeProcs.delete(proc.pid); + clearTimeout(timer); + this.lastMined.delete(id); + resolve(); + }); }); } @@ -404,6 +418,7 @@ export class MemoryManager { let proc: ReturnType; try { proc = spawn(bin, args, { env: this.childEnv(), stdio: ['ignore', 'pipe', 'pipe'] }); + if (proc.pid) this.activeProcs.add(proc.pid); } catch (e) { resolve({ ok: false, output: '', error: e instanceof Error ? e.message : String(e) }); return; @@ -411,6 +426,7 @@ export class MemoryManager { let out = '', err = ''; let settled = false; const settle = (r: { ok: boolean; output: string; error?: string }): void => { + if (proc.pid) this.activeProcs.delete(proc.pid); if (!settled) { settled = true; clearTimeout(timer); resolve(r); } }; proc.stdout?.setEncoding('utf8'); diff --git a/src/main/modelCatalog.ts b/src/main/modelCatalog.ts index 619cdca35e..3a465a061a 100644 --- a/src/main/modelCatalog.ts +++ b/src/main/modelCatalog.ts @@ -17,7 +17,7 @@ import { getText } from './fetchText'; import { parseModelCatalog, type ModelCatalog } from '../shared/modelCatalogPayload'; const CATALOG_URL = - 'https://raw.githubusercontent.com/chaitanyagiri/munder-difflin/main/docs/model-catalog.json'; + 'https://raw.githubusercontent.com/jdell64/agent-stage/main/docs/model-catalog.json'; /** Models ship on a human timescale, and a stale list costs the user nothing — * every command field in the app stays editable. Six hours matches the hero diff --git a/src/main/providerModels.ts b/src/main/providerModels.ts new file mode 100644 index 0000000000..1d9f70adaf --- /dev/null +++ b/src/main/providerModels.ts @@ -0,0 +1,368 @@ +/** + * REFRESH-MODELS (1.1.73; the Human's call, god c6e41f/df3983): ONE models file, filled ONLY when the + * user presses Settings -> Agents & Models -> "Refresh models". Every model picker reads it. The + * hard-coded lists in the renderer are only the floor, used when the file is missing or a provider + * could not be listed. Nothing is looked up at startup or when a picker opens. + * + * Per provider, measured 2026-09-28 (REFRESH-MODELS-PLAN.md): + * - antigravity: `agy models`, one `sluglabel` line per model (agy 1.2.12, no sign-in). + * `--model` takes the LABEL. + * - codex: `codex debug models`, the raw catalog as JSON (codex 0.157.1). Only + * `visibility: "list"` models are offered; the output is ~600 KB. + * - opencode: `opencode models`, one `provider/model` per line (documented; not installed here). + * - claude: NO list command. The Anthropic Models API (GET /v1/models) is used ONLY when the user + * stored an Anthropic BYOK key. Subscription/OAuth credentials are never read. Otherwise the + * curated list, reported as "no list command". + * - everyone else: not installed, or no known list command -> the floor. + * + * Every lookup is async: executables are resolved with `where` / `command -v` through execFile, + * never a sync child process on main. A .cmd/.bat shim runs through cmd.exe /d /s /c with fixed + * arguments; a path with a quote or cmd metacharacter is refused. Each run is hidden and time-boxed. + * The file is re-validated when it is loaded (once per process, then cached; a refresh replaces the + * cached copy with its own validated result), so a hand edit cannot put an unsafe id on a command line. + */ +import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs'; +import { dirname } from 'node:path'; + +import type { ModelEntry, ProviderStatus, ProviderModels, ModelsCatalog, ModelsRefreshRow } from '../shared/modelCatalog'; +export type { ModelEntry, ProviderStatus, ProviderModels }; +export type ModelsFile = ModelsCatalog; + +export const MODELS_FILE_VERSION = 1; +export const LIST_TIMEOUT_MS = 20_000; +const MAX_MODELS = 64; +/** A model id goes on a command line (`--model `): printable, no quotes, no shell or cmd + * metacharacters. `[` `]` for Claude's `[1m]`, `/` for opencode's `provider/model`, spaces and + * parentheses for agy's labels. */ +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9 ._()/:+[\]-]{0,99}$/; +/** A label is only displayed (React escapes it); still: no control characters, bounded. */ +const SAFE_LABEL = /^[^\u0000-\u001f\u007f]{1,80}$/; +const STATUSES: readonly ProviderStatus[] = ['ok', 'not-installed', 'unsupported', 'failed']; + +/** Keep only well-formed, unique entries (by id), at most MAX_MODELS. Total. */ +export function cleanModels(list: unknown): ModelEntry[] { + if (!Array.isArray(list)) return []; + const out: ModelEntry[] = []; + const seen = new Set(); + for (const m of list) { + const id = m && typeof (m as ModelEntry).id === 'string' ? (m as ModelEntry).id.trim() : ''; + const label = m && typeof (m as ModelEntry).label === 'string' ? (m as ModelEntry).label.trim() : ''; + if (!SAFE_ID.test(id) || !SAFE_LABEL.test(label) || seen.has(id)) continue; + seen.add(id); + out.push({ id, label }); + if (out.length >= MAX_MODELS) break; + } + return out; +} + +// ── Parsers (each total: null = nothing usable) ────────────────────────────────────────── + +/** "Gemini 3.8 Flash (Medium)" -> "Gemini 3.8 Flash · Med"; "(Thinking)" dropped. */ +export function agyDisplayLabel(label: string): string { + const m = /^(.*\S) \((High|Medium|Low|Thinking)\)$/.exec(label); + if (!m) return label; + if (m[2] === 'Thinking') return m[1]; + return /^Gemini /.test(m[1]) ? `${m[1]} · ${m[2] === 'Medium' ? 'Med' : m[2]}` : m[1]; +} + +export function parseAgyModels(stdout: unknown): ModelEntry[] | null { + if (typeof stdout !== 'string') return null; + const raw: ModelEntry[] = []; + for (const line of stdout.split(/\r?\n/)) { + const parts = line.split('\t'); + if (parts.length !== 2 || !/^[a-z0-9][a-z0-9._-]{0,79}$/.test(parts[0].trim())) continue; + const id = parts[1].trim(); // the LABEL is what --model takes + raw.push({ id, label: agyDisplayLabel(id) }); + } + // a shortened label that collides keeps the full label + for (const m of raw) if (raw.filter((x) => x.label === m.label).length > 1) m.label = m.id; + const out = cleanModels(raw); + return out.length ? out : null; +} + +export function parseCodexModels(stdout: unknown): ModelEntry[] | null { + if (typeof stdout !== 'string') return null; + const line = stdout.split(/\r?\n/).find((l) => l.trimStart().startsWith('{')); + if (!line) return null; + let j: { models?: Array<{ slug?: unknown; display_name?: unknown; visibility?: unknown; priority?: unknown }> }; + try { j = JSON.parse(line); } catch { return null; } + if (!Array.isArray(j.models)) return null; + const listed = j.models + .filter((m) => m && m.visibility === 'list' && typeof m.slug === 'string') + .sort((a, b) => (typeof a.priority === 'number' ? a.priority : 999) - (typeof b.priority === 'number' ? b.priority : 999)) + .map((m) => ({ id: String(m.slug), label: typeof m.display_name === 'string' && m.display_name.trim() ? m.display_name.trim().replace(/^(GPT-[\d.]+)-/, '$1 ') : String(m.slug) })); + const out = cleanModels(listed); + return out.length ? out : null; +} + +export function parseOpencodeModels(stdout: unknown): ModelEntry[] | null { + if (typeof stdout !== 'string') return null; + const raw = stdout.split(/\r?\n/).map((l) => l.trim()).filter((l) => /^[a-z0-9][a-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._:/-]*$/.test(l)) + .map((id) => ({ id, label: id })); + const out = cleanModels(raw); + return out.length ? out : null; +} + +export function parseAnthropicModels(body: unknown): ModelEntry[] | null { + const data = (body as { data?: Array<{ id?: unknown; display_name?: unknown; type?: unknown }> } | null)?.data; + if (!Array.isArray(data)) return null; + const out = cleanModels(data.filter((m) => m && typeof m.id === 'string') + .map((m) => ({ id: String(m.id), label: typeof m.display_name === 'string' && m.display_name.trim() ? m.display_name.trim() : String(m.id) }))); + return out.length ? out : null; +} + +// ── The file ───────────────────────────────────────────────────────────────────────────── + +/** Re-validate a models file (shape AND every entry). Null when unusable. */ +export function validModelsFile(v: unknown): ModelsFile | null { + const f = v as { version?: unknown; refreshedAt?: unknown; providers?: unknown } | null; + if (!f || f.version !== MODELS_FILE_VERSION || typeof f.refreshedAt !== 'number' || !f.providers || typeof f.providers !== 'object') return null; + const providers: Record = {}; + for (const [id, p] of Object.entries(f.providers as Record)) { + if (!/^[a-z][a-z-]{0,30}$/.test(id) || !p || typeof p !== 'object') continue; + const q = p as ProviderModels; + if (!STATUSES.includes(q.status)) continue; + const entry: ProviderModels = { status: q.status }; + if (typeof q.source === 'string') entry.source = q.source.slice(0, 80); + if (typeof q.fetchedAt === 'number') entry.fetchedAt = q.fetchedAt; + if (typeof q.reason === 'string') entry.reason = q.reason.slice(0, 120); + const models = cleanModels(q.models); + if (models.length) entry.models = models; + if (entry.status === 'ok' && !entry.models) continue; // "ok" with nothing usable is not ok + providers[id] = entry; + } + return { version: MODELS_FILE_VERSION, refreshedAt: f.refreshedAt, providers }; +} + +// ── Running a CLI (async only) ─────────────────────────────────────────────────────────── + +type ExecErr = Error & { code?: unknown; killed?: boolean }; +/** child_process.execFile in the app. It returns the child (its pid is needed to kill a Windows + * process TREE on timeout). `timeout: 0` means no built-in timeout (the caller times the run). */ +export type ModelsExec = (file: string, args: string[], opts: { timeout: number; windowsHide: true; maxBuffer: number; windowsVerbatimArguments?: boolean }, cb: (err: ExecErr | null, stdout: string) => void) => { pid?: number } | void; +export interface CliDeps { + platform: NodeJS.Platform; + env: NodeJS.ProcessEnv; + exec: ModelsExec; + exists: (p: string) => boolean; +} + +/** How long a tree kill may take before the time box gives up waiting for it. */ +export const TREE_KILL_TIMEOUT_MS = 10_000; + +/** + * MODELS-173-AUDIT (Dwight): on Windows the time box must end the whole PROCESS TREE. execFile's own + * timeout kills only the direct child, and for an npm .cmd shim that is cmd.exe: its node/codex + * descendants would keep running, orphaned. So on win32 this times the run itself; on expiry it + * awaits `taskkill /PID /T /F` on the still-live child (the tree goes with it) and then reports a + * timeout. Races are tolerated: the process may exit on its own first, and taskkill may fail. POSIX + * keeps execFile's timeout. + */ +const execP = (d: CliDeps, file: string, args: string[], timeout: number, maxBuffer: number, verbatim = false): Promise<{ stdout: string } | { err: ExecErr }> => + new Promise((resolve) => { + let settled = false; + const done = (r: { stdout: string } | { err: ExecErr }): void => { if (!settled) { settled = true; resolve(r); } }; + const ownTimer = d.platform === 'win32'; + let timer: ReturnType | null = null; + // Once the time box fires, the result IS a timeout: the killed process's own exit (exit 1) can + // arrive before taskkill answers and must not be reported instead. + let timingOut = false; + try { + const child = d.exec(file, args, { timeout: ownTimer ? 0 : timeout, windowsHide: true, maxBuffer, ...(verbatim ? { windowsVerbatimArguments: true } : {}) }, + (err, stdout) => { if (timer) clearTimeout(timer); if (timingOut) return; done(err ? { err } : { stdout: String(stdout) }); }); + if (ownTimer) { + timer = setTimeout(() => { + if (settled) return; + timingOut = true; + const pid = child && typeof child.pid === 'number' ? child.pid : null; + const timedOut = Object.assign(new Error('timeout'), { killed: true }) as ExecErr; + if (!pid) { done({ err: timedOut }); return; } + // Await the tree kill (bounded), then report the timeout; an error or race is not fatal. + let killDone = false; + const finish = (): void => { if (!killDone) { killDone = true; done({ err: timedOut }); } }; + try { + d.exec('taskkill', ['/PID', String(pid), '/T', '/F'], { timeout: TREE_KILL_TIMEOUT_MS, windowsHide: true, maxBuffer: 64 * 1024 }, () => finish()); + } catch { finish(); } + setTimeout(finish, TREE_KILL_TIMEOUT_MS + 1000); + }, timeout); + } + } catch (e) { + if (timer) clearTimeout(timer); + done({ err: e as ExecErr }); + } + }); + +/** Where is `bin`? `where` (Windows) / `command -v` in a login shell, then the usual install dirs. */ +export async function resolveCliAsync(d: CliDeps, bin: string): Promise { + if (!/^[a-z][a-z0-9-]{0,30}$/.test(bin)) return null; + const pick = (out: string, last: boolean): string | null => { + let lines = out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + if (d.platform === 'win32') lines = [...lines.filter((l) => /\.(exe|cmd|bat)$/i.test(l)), ...lines.filter((l) => !/\.(exe|cmd|bat)$/i.test(l))]; + for (const p of last ? lines.reverse() : lines) if (d.exists(p)) return p; + return null; + }; + if (d.platform === 'win32') { + const r = await execP(d, 'where', [bin], 3000, 64 * 1024); + if ('stdout' in r) { const p = pick(r.stdout, false); if (p) return p; } + const la = d.env.LOCALAPPDATA ?? ''; const ad = d.env.APPDATA ?? ''; + for (const c of [`${la}\\${bin}\\bin\\${bin}.exe`, `${ad}\\npm\\${bin}.cmd`]) if (d.exists(c)) return c; + return null; + } + const r = await execP(d, d.env.SHELL || '/bin/sh', ['-lc', `command -v ${bin}`], 3000, 64 * 1024); + if ('stdout' in r) { const p = pick(r.stdout, true); if (p) return p; } + const home = d.env.HOME ?? ''; + for (const c of [`/opt/homebrew/bin/${bin}`, `/usr/local/bin/${bin}`, `${home}/.local/bin/${bin}`]) if (d.exists(c)) return c; + return null; +} + +function reasonOf(err: ExecErr): string { + if (err.killed) return 'timeout'; + if (typeof err.code === 'number') return `exit ${err.code}`; + return String(err.code ?? err.message ?? 'error').slice(0, 80); +} + +/** Run ` ` (a .cmd/.bat through cmd.exe). The args are fixed literals, never user input. */ +export async function runCli(d: CliDeps, exe: string, args: string[], maxBuffer: number, timeout = LIST_TIMEOUT_MS): Promise<{ stdout: string } | { reason: string }> { + let r; + if (d.platform === 'win32' && /\.(cmd|bat)$/i.test(exe)) { + if (/["%^&|<>!\r\n]/.test(exe) || args.some((a) => !/^[a-z-]+$/.test(a))) return { reason: 'unsafe-path' }; + r = await execP(d, d.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', `""${exe}" ${args.join(' ')}"`], timeout, maxBuffer, true); + } else { + r = await execP(d, exe, args, timeout, maxBuffer); + } + return 'stdout' in r ? { stdout: r.stdout } : { reason: reasonOf(r.err) }; +} + +// ── Adapters ───────────────────────────────────────────────────────────────────────────── + +export type ListResult = { status: 'ok'; models: ModelEntry[]; source: string } | { status: Exclude; reason: string; source?: string }; +export type Adapter = () => Promise; + +/** A provider whose CLI can list its models. */ +export function cliAdapter(d: CliDeps, bin: string, args: string[], parse: (s: unknown) => ModelEntry[] | null, maxBuffer = 256 * 1024): Adapter { + const source = `${bin} ${args.join(' ')}`; + return async () => { + const exe = await resolveCliAsync(d, bin); + if (!exe) return { status: 'not-installed', reason: `${bin} not found`, source }; + const r = await runCli(d, exe, args, maxBuffer); + if ('reason' in r) return { status: 'failed', reason: r.reason, source }; + const models = parse(r.stdout); + return models ? { status: 'ok', models, source } : { status: 'failed', reason: 'unparsable output', source }; + }; +} + +/** A provider with no list command: only tells whether it is installed. */ +export function noListAdapter(d: CliDeps, bin: string): Adapter { + return async () => ((await resolveCliAsync(d, bin)) + ? { status: 'unsupported', reason: 'no list command (built-in list kept)' } + : { status: 'not-installed', reason: `${bin} not found` }); +} + +/** Claude: the Anthropic Models API, ONLY with a stored BYOK key (never subscription credentials). */ +export function anthropicAdapter(getKey: () => string | undefined, fetchJson: (url: string, headers: Record, timeoutMs: number) => Promise<{ status: number; body: unknown }>): Adapter { + const source = 'Anthropic Models API (your API key)'; + return async () => { + const key = getKey(); + if (!key) return { status: 'unsupported', reason: 'no list command (no Anthropic API key stored; built-in list kept)' }; + try { + const r = await fetchJson('https://api.anthropic.com/v1/models?limit=100', { 'x-api-key': key, 'anthropic-version': '2023-06-01' }, LIST_TIMEOUT_MS); + if (r.status !== 200) return { status: 'failed', reason: `HTTP ${r.status}`, source }; + const models = parseAnthropicModels(r.body); + return models ? { status: 'ok', models, source } : { status: 'failed', reason: 'unparsable response', source }; + } catch (e) { + return { status: 'failed', reason: /abort|timeout/i.test(String((e as Error)?.name ?? e)) ? 'timeout' : 'request failed', source }; + } + }; +} + +// ── The store ──────────────────────────────────────────────────────────────────────────── + +export type RefreshRow = ModelsRefreshRow; + +export class ProviderModelStore { + private file: ModelsFile | null = null; + private loaded = false; + private running: Promise<{ file: ModelsFile; rows: RefreshRow[] }> | null = null; + + constructor(private readonly deps: { path: string; now: () => number; log?: (row: Record) => void }) {} + + /** The validated file, or null. A FILE READ only (once per process); never a lookup. */ + read(): ModelsFile | null { + if (!this.loaded) { + this.loaded = true; + try { if (existsSync(this.deps.path)) this.file = validModelsFile(JSON.parse(readFileSync(this.deps.path, 'utf8'))); } catch { this.file = null; } + } + return this.file; + } + + isRefreshing(): boolean { return this.running !== null; } + + /** The button: run every adapter in parallel, merge, write atomically, log one row. One at a time. */ + refresh(adapters: Record): Promise<{ file: ModelsFile; rows: RefreshRow[] }> { + if (this.running) return this.running; + this.running = (async () => { + try { + const prev = this.read(); + const ids = Object.keys(adapters); + const results = await Promise.all(ids.map(async (id) => { + const t0 = this.deps.now(); + let r: ListResult; + try { r = await adapters[id](); } catch { r = { status: 'failed', reason: 'error' }; } + return { id, r, ms: this.deps.now() - t0 }; + })); + const providers: Record = {}; + const rows: RefreshRow[] = []; + for (const { id, r, ms } of results) { + const before = prev?.providers[id]; + const beforeIds = new Set((before?.status === 'ok' || before?.status === 'failed' ? before.models ?? [] : []).map((m) => m.id)); + if (r.status === 'ok') { + providers[id] = { status: 'ok', source: r.source, fetchedAt: this.deps.now(), models: r.models }; + const now = new Set(r.models.map((m) => m.id)); + rows.push({ provider: id, status: 'ok', count: r.models.length, added: r.models.map((m) => m.id).filter((x) => !beforeIds.has(x)), removed: [...beforeIds].filter((x) => !now.has(x)), ms }); + } else if (r.status === 'failed' && before?.models?.length) { + // A failed run keeps the last good list (the pickers keep using it). + providers[id] = { status: 'failed', source: r.source ?? before.source, fetchedAt: before.fetchedAt, models: before.models, reason: r.reason }; + rows.push({ provider: id, status: 'failed', count: before.models.length, added: [], removed: [], reason: r.reason, keptLast: true, ms }); + } else { + providers[id] = { status: r.status, reason: r.reason, ...(r.source ? { source: r.source } : {}) }; + rows.push({ provider: id, status: r.status, count: 0, added: [], removed: [], reason: r.reason, ms }); + } + } + const file: ModelsFile = { version: MODELS_FILE_VERSION, refreshedAt: this.deps.now(), providers }; + try { + mkdirSync(dirname(this.deps.path), { recursive: true }); + const tmp = `${this.deps.path}.tmp`; + writeFileSync(tmp, JSON.stringify(file, null, 1)); + renameSync(tmp, this.deps.path); + } catch { /* the in-memory copy still serves this run */ } + this.file = file; + this.loaded = true; + try { + this.deps.log?.({ kind: 'models-refresh', providers: Object.fromEntries(rows.map((x) => [x.provider, { status: x.status, count: x.count, added: x.added.length, removed: x.removed.length, ...(x.reason ? { reason: x.reason } : {}) }])) }); + } catch { /* diagnostics */ } + return { file, rows }; + } finally { + this.running = null; + } + })(); + return this.running; + } +} + +/** Which providers the button queries, and how (the app's wiring). */ +export function defaultAdapters(d: CliDeps, getAnthropicKey: () => string | undefined, fetchJson: Parameters[1]): Record { + return { + claude: anthropicAdapter(getAnthropicKey, fetchJson), + codex: cliAdapter(d, 'codex', ['debug', 'models'], parseCodexModels, 4 * 1024 * 1024), + antigravity: cliAdapter(d, 'agy', ['models'], parseAgyModels), + opencode: cliAdapter(d, 'opencode', ['models'], parseOpencodeModels, 1024 * 1024), + gemini: noListAdapter(d, 'gemini'), + grok: noListAdapter(d, 'grok'), + kimi: noListAdapter(d, 'kimi'), + qwen: noListAdapter(d, 'qwen'), + crush: noListAdapter(d, 'crush'), + pi: noListAdapter(d, 'pi'), + copilot: noListAdapter(d, 'copilot'), + cursor: noListAdapter(d, 'cursor-agent') + }; +} diff --git a/src/main/pty.ts b/src/main/pty.ts index 5c1a03a040..cca531209a 100644 --- a/src/main/pty.ts +++ b/src/main/pty.ts @@ -62,7 +62,7 @@ interface PtySession { /** Epoch ms of the most recent byte this PTY emitted (bumped in onData). The * heartbeat (Lane A #1) reads this for two things: floor-quiet detection (an * agent printing/thinking counts as activity even before it writes a hive - * file) and the idle handshake that gates god's PTY nudge (never type into a + * file) and the idle handshake that gates orchestrator's PTY nudge (never type into a * PTY that produced output in the last few seconds = mid-stream). */ lastOutputAt: number; /** A bounded ring of the most recent output bytes, kept ONLY so an abnormal diff --git a/src/main/realtime.ts b/src/main/realtime.ts index fadaaf002e..6eb9a1c07e 100644 --- a/src/main/realtime.ts +++ b/src/main/realtime.ts @@ -1,5 +1,5 @@ /** - * Realtime Michael — main-process ephemeral-token mint (card rt-1, Phase 1). + * Realtime voice — main-process ephemeral-token mint (card rt-1, Phase 1). * * The voice orchestrator (OpenAI `gpt-realtime-2`, speech-to-speech over WebRTC) * connects from the RENDERER. The renderer must NEVER hold the real OpenAI key, so @@ -12,7 +12,7 @@ * * Phase 1 is read-only — this module ONLY mints (no action tools; that's rt-5). * - * Branch feat/realtime-michael. See board.md "🎙 REALTIME MICHAEL". + * Branch feat/realtime-voice. See board.md "🎙 REALTIME VOICE". */ import { ipcMain } from 'electron'; import { getSecret, hasSecret } from './integrations'; @@ -43,7 +43,7 @@ export type MintResult = | { ok: false; error: string; code?: string }; /** Whether a BYOK OpenAI key is stored (presence only — no decryption). Gates the - * Realtime Michael voice toggle in the renderer, the way `hasGroqKey` gates the + * Realtime voice voice toggle in the renderer, the way `hasGroqKey` gates the * Free Flow mic button. */ export function hasOpenAiKey(): boolean { return hasSecret(OPENAI_KEY_REF); diff --git a/src/main/realtimeActions.ts b/src/main/realtimeActions.ts index 21ebd3e816..c7fc725dbf 100644 --- a/src/main/realtimeActions.ts +++ b/src/main/realtimeActions.ts @@ -1,7 +1,7 @@ /** - * Realtime Michael — voice ACTION spine (card rt-5, Phase 2). + * Realtime voice — voice ACTION spine (card rt-5, Phase 2). * - * Phase 1 gave voice-Michael READ tools. Phase 2 gives him WRITE access: he can + * Phase 1 gave the voice orchestrator READ tools. Phase 2 gives him WRITE access: he can * ping/dispatch agents, edit the task board, steer/pause/halt/kill workers, hire * new ones, and edit schedules — entirely by voice. Because the confirm surface is * VOICE-ONLY (the human declined on-screen confirm cards), the echo-back spine in @@ -21,16 +21,16 @@ * instant (the renderer mutes the mic during the confirm tool-call — see * session.ts agent_tool_start), (4) the circuit-breaker still gates (actions go * through the same control path it owns). - * • HARD ALLOWLIST — kill/pause/halt on the god orchestrator, and any mass / + * • HARD ALLOWLIST — kill/pause/halt on the orchestrator, and any mass / * all-agent op, are VOICE-FORBIDDEN even with a valid confirm — rejected * outright, no pending created. * - * Every committed action is attributed to actor `michael-voice` (a log stamp on - * every verb + `from: michael-voice` on messages). rt-7 deepens this into a live - * god-PTY cross-notify; rt-5 just needs the attribution present. + * Every committed action is attributed to actor `orchestrator-voice` (a log stamp on + * every verb + `from: orchestrator-voice` on messages). rt-7 deepens this into a live + * orchestrator-PTY cross-notify; rt-5 just needs the attribution present. * * Thin wrappers ONLY — no new orchestration logic. Each verb maps onto a main fn - * the god PTY already uses (hive.send / atomic task mutations / spawnAgentCore / + * the orchestrator PTY already uses (hive.send / atomic task mutations / spawnAgentCore / * control.pause+steer+halt / pty kill / missions save), injected via deps so this * module stays decoupled from index.ts wiring. */ @@ -39,9 +39,10 @@ import type { HiveMessage, HiveTask, Registry } from './hive'; import type { ScheduledMission } from './config'; import { inferAgentProvider } from '../shared/agentProvider'; import { clearCommandForProvider } from '../shared/providerAutomation'; -import { resolveGodName } from '../shared/godIdentity'; +import { resolveOrchestratorName } from '../shared/orchestratorIdentity'; +import { scanThemes } from './themes'; -export const VOICE_ACTOR = 'michael-voice'; +export const VOICE_ACTOR = 'orchestrator-voice'; /** A minimal spawn spec — index.ts adapts it to its AgentSpawnOptions + spawnAgentCore. */ export interface RealtimeSpawnSpec { @@ -139,7 +140,7 @@ const SETTING_POLICY: Record ({ - id, name: m.name || id, isGod: !!m.isGod || id === reg.godId + const mk = (id: string, m: { name?: string; isOrchestrator?: boolean }): ResolvedAgent => ({ + id, name: m.name || id, isOrchestrator: !!m.isOrchestrator || id === reg.orchestratorId }); // exact id const byId = entries.find(([id]) => id.toLowerCase() === t); if (byId) return mk(byId[0], byId[1]); - // 'god' / 'michael' alias for the orchestrator - if ((t === 'god' || t === 'michael' || t === 'the god') && reg.godId) - return mk(reg.godId, reg.agents[reg.godId] ?? {}); + // 'orchestrator' alias for the orchestrator + if ((t === 'orchestrator' || t === 'god' || t === 'the orchestrator') && reg.orchestratorId) + return mk(reg.orchestratorId, reg.agents[reg.orchestratorId] ?? {}); // exact name, prefer live const byName = entries.filter(([, m]) => (m.name || '').toLowerCase() === t); const liveName = byName.filter(([, m]) => !m.archived); @@ -272,10 +273,10 @@ function attribute(deps: RealtimeActionDeps, verb: string, target: string, extra } catch { /* attribution is best-effort — never block the action */ } - // rt-7 dual-orchestrator coord: tell the god PTY what voice-Michael just COMMITTED, so + // rt-7 dual-orchestrator coord: tell the orchestrator PTY what the voice orchestrator just COMMITTED, so // the two autonomous orchestrators stay aware and don't make duplicate/contradictory // moves. attribute() only runs on committed writes (soft execs + post-confirm commits), - // so god is never notified for a merely-proposed/uncommitted destructive action. + // so orchestrator is never notified for a merely-proposed/uncommitted destructive action. try { const detail = typeof extra.objective === 'string' ? `: ${extra.objective}` @@ -285,18 +286,18 @@ function attribute(deps: RealtimeActionDeps, verb: string, target: string, extra : typeof extra.action === 'string' ? ` (${extra.action})` : ''; const reg = deps.hiveRegistry(); - const godName = resolveGodName(reg.agents[reg.godId ?? 'god']?.name); + const orchestratorName = resolveOrchestratorName(reg.agents[reg.orchestratorId ?? 'orchestrator']?.name); deps.hiveSend( { - to: 'god', + to: 'orchestrator', act: 'inform', subject: `voice action: ${verb} ${target}`, - body: `${godName} (voice orchestrator, ${VOICE_ACTOR}) just did: ${verb} on ${target}${detail}. Heads-up so we don't duplicate — the board is the single source of truth.` + body: `${orchestratorName} (voice orchestrator, ${VOICE_ACTOR}) just did: ${verb} on ${target}${detail}. Heads-up so we don't duplicate — the board is the single source of truth.` }, VOICE_ACTOR ); } catch { - /* god cross-notify is best-effort — never block the action */ + /* orchestrator cross-notify is best-effort — never block the action */ } } @@ -310,7 +311,7 @@ function execPing(deps: RealtimeActionDeps, a: Record): ActionR const r = resolveAgent(str(a.agentId) || str(a.target) || str(a.name), reg); if ('error' in r) return { ok: false, spoken: r.error }; const message = str(a.message) || str(a.text) || 'Checking in.'; - deps.hiveSend({ to: r.id, act: 'inform', subject: `Voice ping from ${resolveGodName(reg.agents[reg.godId ?? 'god']?.name)}`, body: message }, VOICE_ACTOR); + deps.hiveSend({ to: r.id, act: 'inform', subject: `Voice ping from ${resolveOrchestratorName(reg.agents[reg.orchestratorId ?? 'orchestrator']?.name)}`, body: message }, VOICE_ACTOR); attribute(deps, 'ping', r.id); return { ok: true, spoken: `Pinged ${r.name}.` }; } @@ -326,7 +327,7 @@ function execDispatch(deps: RealtimeActionDeps, a: Record): Act `OBJECTIVE: ${objective}\n` + `CONTEXT: ${str(a.context) || '(none given)'}\n` + `CONSTRAINTS: ${str(a.constraints) || '(use your judgement; respect the guardrails)'}\n` + - `DONE WHEN: ${str(a.doneWhen) || str(a.done) || 'you report the outcome back to god'}`; + `DONE WHEN: ${str(a.doneWhen) || str(a.done) || 'you report the outcome back to the orchestrator'}`; const msg = deps.hiveSend( { to: r.id, act: 'request', subject: `Voice dispatch: ${objective.slice(0, 60)}`, body, requires_reply: true }, VOICE_ACTOR @@ -608,18 +609,18 @@ function proposeDestructive(deps: RealtimeActionDeps, verb: string, a: Record c.toUpperCase()) : provider) || 'Worker'; - const godCwd = reg.godId ? reg.agents[reg.godId]?.cwd : undefined; + const orchestratorCwd = reg.orchestratorId ? reg.agents[reg.orchestratorId]?.cwd : undefined; const cwd = - str(a.cwd) || godCwd || Object.values(reg.agents).find((m) => m.cwd)?.cwd || ''; + str(a.cwd) || orchestratorCwd || Object.values(reg.agents).find((m) => m.cwd)?.cwd || ''; if (!cwd) return { ok: false, spoken: 'I need a working directory to hire into — none is configured.' }; const command = str(a.command) || PROVIDER_COMMAND[provider] || 'claude'; const id = `${slug(name)}-${shortId()}`; @@ -699,9 +700,9 @@ function proposeDestructive(deps: RealtimeActionDeps, verb: string, a: Record p.available).map((p) => p.id); + if (!availableThemes.includes(value as string) && value !== 'office') { + return { ok: false, spoken: `officeTheme must be one of: ${availableThemes.join(', ')}.` }; + } + } else if (policy.values && !policy.values.includes(value as string)) { return { ok: false, spoken: `${key} must be one of: ${policy.values.join(', ')}.` }; } } diff --git a/src/main/realtimeCompletionWatcher.ts b/src/main/realtimeCompletionWatcher.ts index 5140317ca5..b158561ff2 100644 --- a/src/main/realtimeCompletionWatcher.ts +++ b/src/main/realtimeCompletionWatcher.ts @@ -1,12 +1,12 @@ /** - * Realtime Michael — completion watcher (card rt-12, Phase 2, "respond when done"). + * Realtime voice — completion watcher (card rt-12, Phase 2, "respond when done"). * - * When voice-Michael DISPATCHES work (fire-and-notify, the default pattern), he does + * When the voice orchestrator DISPATCHES work (fire-and-notify, the default pattern), he does * NOT block on it. This module is the main-process engine that watches each dispatched * task for completion and EMITS a completion event so the rest of the realtime stack can - * make Michael speak it unprompted ("Oscar finished — want details?"). + * make the orchestrator speak it unprompted ("Oscar finished — want details?"). * - * OWNERSHIP / SEAM (rt-12 split, god-ruled 2026-06-25): this module is OWNED by Jim and + * OWNERSHIP / SEAM (rt-12 split, orchestrator-ruled 2026-06-25): this module is OWNED by Jim and * is deliberately DISJOINT from Kevin's realtime CORE files. It NEVER imports session.ts * / the live RealtimeSession / electron — it only takes injected readers + a clock and * EMITS via callbacks. Kevin's side subscribes `onCompletion(...)` and pushes the event @@ -19,10 +19,10 @@ * (a) the dispatched task's card flips to `done` in tasks.json, OR * (b) an inbox done-msg arrives from the assignee (a reply to the dispatch, after it). * - * Branch feat/realtime-michael. See board.md "🎙 REALTIME MICHAEL". + * Branch feat/realtime-voice. See board.md "🎙 REALTIME VOICE". */ -/** A unit of work voice-Michael dispatched and is now awaiting completion of. */ +/** A unit of work the voice orchestrator dispatched and is now awaiting completion of. */ export interface PendingDispatch { /** Stable id for this dispatch (the watcher key). e.g. the dispatch message id. */ correlationId: string; @@ -84,7 +84,7 @@ export interface CompletionResult { * The completion object the watcher emits via `onCompletion` AND returns from * `drainQueuedCompletions()` — the SAME shape (rt-12 contract lock with Kevin). Kevin * forwards it verbatim over the main→renderer push channel and into warm-start, so every - * field here reaches Michael. `summary` is the human-speakable line; `completedAt` is + * field here reaches the orchestrator. `summary` is the human-speakable line; `completedAt` is * epoch-ms. The trailing fields are extra context (safe to ignore on the wire). */ export interface RealtimeCompletion { @@ -136,7 +136,7 @@ const INJECTION_PATTERNS: RegExp[] = [ /** * N3 defense-in-depth: the spoken summary embeds `objective`, which comes from a * possibly-crafted task. Strip control chars, neutralize prompt-injection lead-ins, collapse - * whitespace, and cap length so a malicious objective can't steer what Michael says or does. + * whitespace, and cap length so a malicious objective can't steer what the orchestrator says or does. * (Kevin also neutralizes at the session.ts injection seam — this is the watcher-half belt.) */ function neutralizeForVoice(text: string): string { diff --git a/src/main/realtimeCost.ts b/src/main/realtimeCost.ts index 08d3915091..0c77d23f74 100644 --- a/src/main/realtimeCost.ts +++ b/src/main/realtimeCost.ts @@ -1,5 +1,5 @@ /** - * Realtime Michael — main-process cost helpers (card rt-9, cost-guard). + * Realtime voice — main-process cost helpers (card rt-9, cost-guard). * * Re-exports the shared realtime audio cost helpers so a main-side caller can price * a realtime usage delta without reaching into ../shared directly. These feed the diff --git a/src/main/realtimeFloorWatcher.ts b/src/main/realtimeFloorWatcher.ts index 2009feec09..420345485d 100644 --- a/src/main/realtimeFloorWatcher.ts +++ b/src/main/realtimeFloorWatcher.ts @@ -1,5 +1,5 @@ /** - * Realtime Michael — floor delta watcher (v0.3.4). + * Realtime voice — floor delta watcher (v0.3.4). * * The voice session's context strategy is "snapshot at connect + APPEND-ONLY * deltas" (never session.update on instructions — that busts the prompt cache). @@ -18,7 +18,7 @@ interface RegistryLike { agents: Record; - godId?: string | null; + orchestratorId?: string | null; } export interface FloorWatcherDeps { diff --git a/src/main/reflect.ts b/src/main/reflect.ts index ac625c011a..8808a184a7 100644 --- a/src/main/reflect.ts +++ b/src/main/reflect.ts @@ -29,7 +29,7 @@ import { runHiddenClaude } from './hiddenClaude'; /** Total memory.md budget — mirrors the janitor's CONTEXT_BUDGET_BYTES (128 KB). */ const BUDGET_BYTES = 131_072; -/** Cheap tail-summarizer (DECIDED by god). The verify gate covers quality. */ +/** Cheap tail-summarizer (DECIDED by orchestrator). The verify gate covers quality. */ const CONDENSE_MODEL = 'claude-haiku-4-5'; /** Hard cap so a wedged headless run can't stall the reflect loop. */ const DEFAULT_TIMEOUT_MS = 180_000; @@ -128,7 +128,10 @@ export class MemoryReflector { private getCommand: () => string, private getMemoryEnv: () => Record, private getSettings: () => ReflectSettings, - private appendLog: (event: Record) => void + private appendLog: (event: Record) => void, + /** The hidden model call. Injectable ONLY so the summarize path can be driven + * deterministically in tests; production always uses the real print process. */ + private runHidden: typeof runHiddenClaude = runHiddenClaude ) {} // — lifecycle (mirrors MemoryManager) — @@ -293,7 +296,7 @@ export class MemoryReflector { pinned?.trim() || '(none)' ].join('\n'); - const result = await runHiddenClaude(prompt, { + const result = await this.runHidden(prompt, { model: CONDENSE_MODEL, cwd: home, command: this.getCommand(), @@ -303,10 +306,10 @@ export class MemoryReflector { timeoutMs: DEFAULT_TIMEOUT_MS, }); - if (!result.ok || !result.text) { + if (!result.ok || !result.result) { throw new Error(result.error ?? 'condense: hidden session returned no text'); } - const parsed = parseSummary(result.text); + const parsed = parseSummary(result.result); if (!parsed) throw new Error('condense: response contained no parseable summary'); return parsed; } diff --git a/src/main/slack.ts b/src/main/slack.ts index ab339a85ef..c01742e2f9 100644 --- a/src/main/slack.ts +++ b/src/main/slack.ts @@ -2,7 +2,7 @@ * SlackWebhookServer — receive Slack messages and hand them to the harness. * * A bare `node:http` server (no @slack/bolt) that implements just enough of the - * Slack Events API to let the user pipe a channel's messages into Michael's + * Slack Events API to let the user pipe a channel's messages into the orchestrator's * message queue: * - verifies EVERY request with Slack's signing-secret HMAC over the RAW body * plus a 5-minute replay-timestamp guard (403 on any failure), diff --git a/src/main/standupDelta.ts b/src/main/standupDelta.ts new file mode 100644 index 0000000000..f850365acf --- /dev/null +++ b/src/main/standupDelta.ts @@ -0,0 +1,341 @@ +import { createHash } from 'node:crypto'; + +/** + * TE0 — the standup delta gate. + * + * WHAT IS ACTUALLY EXPENSIVE. The scheduler's ops-standup costs nothing in main: + * `fire()` writes one small JSON file into god's inbox. The bill arrives in the + * RENDERER, where the inbox-wake loop (useHive.ts, effect #3) notices an inbox id + * it has not nudged for and queues a nudge that effect #4 types into god's PTY + * once he is idle. That typed nudge is a full model turn over god's whole session + * prefix — measured at ~0.63M token units per no-change standup, rising as the + * session grows. Suppressing the dispatch suppresses the turn with nothing else to + * change: no new inbox id means the wake loop has nothing fresh to nudge about. + * + * So this module answers one question, deterministically and locally, with no + * model in the loop: HAS THE FLOOR ACTUALLY CHANGED SINCE THE LAST STANDUP? + * + * ─── RULE 1: NOTHING THE STANDUP ITSELF DISTURBS MAY BE AN INPUT ─── + * + * LOAD-BEARING. Get this wrong and the gate is decorative. + * + * The obvious half is prose. god is the sole scribe of `board.md` and appends a + * STANDUP line to it on EVERY standup, no-change ones included (the six that + * motivated TE0 are board.md:123-128, all "No change: 22/22 done, floor idle"), + * and the same goes for a task card's `notes` and `result`. Hash any of it and + * each standup moves the fingerprint the NEXT standup compares against. + * + * THE HALF THAT IS EASY TO MISS, AND DID GET MISSED HERE ONCE: coordination + * FILE MTIMES. The first version of this module hashed each agent's newest + * coordination mtime, which looks like a pure observation of the floor and is + * not. Dispatching the standup writes a message into god's inbox — that is the + * gate's own action moving an input. god then handles it: inbox/.done moves, + * memory.md moves, the outbox moves. The standup also asks every working agent + * to summarise and compact, which moves THEIR memory.md and outbox. The harness + * and the prep assistant write on their own timers. So the next tick always saw + * a delta, always dispatched, and suppressed nothing — a gate that looked + * finished and was permanently open. + * + * THE RULE, stated so it survives the next edit: AN INPUT THE OBSERVATION ITSELF + * PERTURBS CANNOT MEASURE CHANGE. Mtimes are therefore not floor inputs at all — + * not god's, not any agent's — and no amount of excluding god alone would have + * fixed it, because the compaction request moves the workers' files too. + * + * What is left is content-level and moves only when real work does: who exists, + * whether they are on hold, their breaker level, whether they own a live + * terminal, how much ACTIONABLE (non-system) mail they hold, the task ledger's + * {id, status, assignee}, and the queued-spawn and crash counts. The scheduler's + * own message is excluded from the mail count by SYSTEM_SENDERS at the call + * site, so the dispatch cannot inflate its own signal. god is included on the + * same terms as everyone else: his breaker, hold, terminal and real mail are + * floor facts, and none of them move because a standup happened. + * + * A cost worth naming: an agent working hard while changing none of the above + * reads as "unchanged". That is acceptable — the standup exists to catch blocks + * and unowned work, which are all content-level. STALLS ARE NOT THIS MODULE'S + * JOB: the floor has a purpose-built quiet/stall detector (the heartbeat), and + * the owner ruled that stall detection stays there rather than being rebuilt + * inside the gate. See RULE 3. + * + * ─── RULE 2: FAIL OPEN ─── + * + * Every path that cannot prove "nothing changed" must DISPATCH. Missing baseline, + * gate disabled, unreadable state, forced by the operator — all dispatch. The + * cost of a wrong dispatch is one standup we did not need; the cost of a wrong + * suppression is an unattended floor. Those are not the same mistake, so they do + * not get the same default. + * + * ─── RULE 3: NO PERIODIC FALLBACK ─── + * + * There is deliberately NO maximum age. A provably unchanged floor may go + * INDEFINITELY without a standup. That is the point of TE0 rather than an + * oversight, and it is an owner ruling: "Do NOT run a standup merely because a + * maximum age has elapsed... Do not add a periodic 12h or 24h fallback." + * + * An earlier revision of this file had a 24h expiry and argued for it in these + * comments. The argument was that a frozen board is not proof that nothing needs + * review. That is true, but it is an argument for a STALL DETECTOR, and answering + * it here would have built a second one beside the heartbeat — two controls + * owning one question, which is exactly the failure this codebase already paid + * for once, when compact-maintenance and the context trigger both owned + * compaction. A periodic fallback also stops meaning anything as the period + * approaches the cadence: at the live 6h cadence a 6h max-age suppresses nothing + * at all, so the knob only ever looked like a safety net. + * + * The structural consequence, and the reason it stays gone: `decideStandup` TAKES + * NO CLOCK. It cannot dispatch on elapsed time because it cannot observe elapsed + * time. Reintroducing an age-based reason is therefore not a one-line change to a + * comparison — it requires widening the signature, which is asserted against. + */ + +/** One agent's contribution to the fingerprint. Every field is a floor input that + * moves on its own; none of it is written by the standup that reads it. */ +export interface AgentFloorState { + id: string; + onHold: boolean; + /** Circuit-breaker level. An agent tripping to constrained/stopped between two + * standups is exactly the kind of change a standup exists to catch. */ + breaker: string; + hasLivePty: boolean; + /** Unread mail EXCLUDING the scheduler's own noise — see SYSTEM_SENDERS at the + * call site. Counting our own beats here would be the Rule 1 mistake again. */ + actionableInbox: number; +} + +/** A task card reduced to the three fields that mean something changed. NOT + * `notes`, NOT `result`, NOT `title` — see Rule 1. */ +export interface TaskFloorState { + id: string; + status: string; + assignee: string; +} + +/** The complete input surface of the fingerprint. Deliberately small and closed: + * a future field is a deliberate edit here, not something that leaks in. */ +export interface FloorState { + agents: AgentFloorState[]; + tasks: TaskFloorState[]; + spawnRequests: number; + crashes: number; + /** What the collector could NOT read, by short label. Non-empty means the floor + * was only partly observed. + * + * This exists because the first version swallowed read errors into zeros, and a + * zero is indistinguishable from a genuinely empty inbox. Two consecutive + * failures to read the same directory therefore produced two IDENTICAL + * fingerprints and the gate SUPPRESSED — silence caused by a broken observation + * read as silence on the floor. An unobserved floor is an unknown floor, and + * `decideStandup` dispatches on it. */ + unknown: string[]; +} + +/** Per-mission gate settings. Absent ⇒ off ⇒ today's unconditional dispatch. + * + * One field, and that is the whole knob. There is no `maxAgeMs` — see RULE 3. */ +export interface DeltaGate { + enabled: boolean; +} + +export type StandupReason = + | 'gate-off' // no gate configured — behave exactly as before TE0 + | 'forced' // operator pressed run-now + | 'state-unknown' // the floor could not be fully read — never suppress on that + | 'no-baseline' // nothing to compare against yet + | 'delta' // the floor moved + | 'no-delta'; // the only outcome that suppresses a model turn — and it may + // now repeat indefinitely, by design (RULE 3) + +export interface StandupDecision { + dispatch: boolean; + reason: StandupReason; + /** Always present, including on a skip: the caller stores it as the next + * comparison baseline only when it dispatches, but logs it either way. */ + fingerprint: string; +} + +/** One line of `/standup-skips.jsonl`. + * + * NOT `log.jsonl`, and that is a decision rather than a preference: log.jsonl's + * mtime is an INPUT to isFloorQuiet(), so recording every skip there would keep + * the floor looking permanently busy and quietly disable the heartbeat's + * re-engage. The heartbeat is disabled in the live config today, which is + * precisely what would have let that ship unnoticed. */ +export interface StandupSkipRecord { + ts: number; + kind: 'standup-skipped'; + missionId: string; + fingerprint: string; + reason: StandupReason; + sinceLastDispatchMs: number | null; +} + +/** Reduce a raw tasks.json payload to the ledger the fingerprint hashes. + * + * This is where Rule 1 is enforced for tasks, so it is exported and tested + * directly rather than inlined at the call site: prose fields must be dropped + * HERE, before anything can hash them. Accepts the array form and the + * `{tasks:[...]}`/`{cards:[...]}` wrappers the hive has used. */ +export function projectTasks(raw: unknown): TaskFloorState[] { + const list = Array.isArray(raw) + ? raw + : Array.isArray((raw as { tasks?: unknown })?.tasks) + ? (raw as { tasks: unknown[] }).tasks + : Array.isArray((raw as { cards?: unknown })?.cards) + ? (raw as { cards: unknown[] }).cards + : []; + const out: TaskFloorState[] = []; + for (const t of list) { + if (!t || typeof t !== 'object') continue; + const c = t as Record; + out.push({ + id: String(c.id ?? ''), + status: String(c.status ?? ''), + assignee: String(c.assignee ?? '') + }); + } + return out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); +} + +/** Canonical text for hashing. + * + * Nested ARRAYS, not objects: an array's order is written here, so the digest + * cannot drift because a key was renamed or because V8 handed back a different + * insertion order. Collections are sorted by id so "same floor, different + * readdir order" is the same fingerprint. */ +export function canonicalize(state: FloorState): string { + const agents = [...state.agents] + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + .map((a) => [ + a.id, + a.onHold ? 1 : 0, + a.breaker, + a.hasLivePty ? 1 : 0, + a.actionableInbox + ]); + const tasks = [...state.tasks] + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + .map((t) => [t.id, t.status, t.assignee]); + return JSON.stringify(['te0/1', agents, tasks, state.spawnRequests, state.crashes]); +} + +/** sha256 of the canonical form. Hex, so it is greppable in the skip log. */ +export function fingerprintFloor(state: FloorState): string { + return createHash('sha256').update(canonicalize(state), 'utf8').digest('hex'); +} + +/** + * The whole decision. Pure: no clock, no filesystem, no Electron — `now` and the + * state are handed in, so every branch is reachable from a unit test. + * + * Order matters. `gate-off` and `forced` come before any comparison so neither + * can be defeated by a hashing bug, and `no-baseline` precedes the comparison so + * a first run (or a cleared config) always dispatches. + * + * NOTE THE ABSENT PARAMETERS. There is no `now` and no `lastDispatchAt`, because + * after RULE 3 there is nothing left for a clock to decide. Every dispatch reason + * is a fact about the floor or about the operator; none is a fact about the + * calendar. Keeping the clock out of the signature is what makes that permanent. + */ +export function decideStandup(input: { + state: FloorState; + gate?: DeltaGate; + lastFingerprint?: string; + forced?: boolean; +}): StandupDecision { + const fingerprint = fingerprintFloor(input.state); + if (!input.gate?.enabled) return { dispatch: true, reason: 'gate-off', fingerprint }; + if (input.forced) return { dispatch: true, reason: 'forced', fingerprint }; + // Before any comparison: a floor we could not fully read is not a floor we can + // prove unchanged. Two failed reads in a row hash identically, so without this + // a persistent read error would suppress every standup indefinitely. + if (input.state.unknown.length) return { dispatch: true, reason: 'state-unknown', fingerprint }; + if (!input.lastFingerprint) return { dispatch: true, reason: 'no-baseline', fingerprint }; + if (input.lastFingerprint !== fingerprint) return { dispatch: true, reason: 'delta', fingerprint }; + // Nothing follows this line. A floor that is readable, gated, un-forced, has a + // baseline and still matches it is a floor nobody needs to be woken about — for + // as long as that stays true, however long that is. RULE 3. + return { dispatch: false, reason: 'no-delta', fingerprint }; +} + +/** Everything one scheduler tick touches, injected. + * + * The decision function alone could never have caught the mtime bug: the defect + * was in what the COLLECTOR fed it, and in the fact that dispatching changed the + * next collection. Proving the gate works therefore needs a test that drives real + * ticks — dispatch, apply the effects a dispatch actually has, tick again — and + * that is only possible if the tick's effects are behind an interface. Hence this + * seam: `fire()` in index.ts supplies the real implementations, a test supplies a + * fake floor it can mutate between ticks. */ +export interface StandupTickDeps { + /** The PERSISTED mission, re-read each tick (never the armed closure's copy). */ + readMission: () => { + deltaGate?: DeltaGate; + lastDeltaFingerprint?: string; + lastDispatchAt?: number; + }; + collect: () => FloorState; + now: () => number; + send: () => void; + recordSkip: (record: StandupSkipRecord) => void; + /** Always called, dispatch or skip: `lastFiredAt` is the timer's clock. */ + stamp: (patch: { + lastFiredAt: number; + lastDeltaFingerprint?: string; + lastDispatchAt?: number; + }) => void; +} + +/** One scheduler tick: decide, do exactly one of send/record-skip, then stamp. + * + * The ordering is the contract. Exactly one of `send` and `recordSkip` runs, and + * `stamp` runs unconditionally afterwards — a skipped tick still has to advance + * `lastFiredAt` or syncMissions re-arms with a zero delay and spins the mission. + * Only a real dispatch advances the baseline and `lastDispatchAt`; advancing + * either on a skip would make the floor look freshly reviewed when no one has + * reviewed it. + * + * `lastDispatchAt` OUTLIVES the max-age reason it was first added for, and that + * is deliberate rather than leftover: `skipRecord` still reads it to report + * `sinceLastDispatchMs`. It no longer gates anything — the decision cannot see + * it — but it is the only way an operator can answer "how long has this floor + * been quiet", and under RULE 3 that span is unbounded, so the question matters + * MORE than it did when a 24h ceiling answered it. Diagnostics, not control. */ +export function runStandupTick( + missionId: string, + deps: StandupTickDeps, + forced = false +): StandupDecision { + const m = deps.readMission(); + const now = deps.now(); + const decision = decideStandup({ + state: deps.collect(), + gate: m.deltaGate, + lastFingerprint: m.lastDeltaFingerprint, + forced + }); + if (decision.dispatch) deps.send(); + else deps.recordSkip(skipRecord(missionId, decision, now, m.lastDispatchAt)); + deps.stamp( + decision.dispatch + ? { lastFiredAt: now, lastDeltaFingerprint: decision.fingerprint, lastDispatchAt: now } + : { lastFiredAt: now } + ); + return decision; +} + +/** Build the durable record of a suppressed standup. */ +export function skipRecord( + missionId: string, + decision: StandupDecision, + now: number, + lastDispatchAt?: number +): StandupSkipRecord { + return { + ts: now, + kind: 'standup-skipped', + missionId, + fingerprint: decision.fingerprint, + reason: decision.reason, + sinceLastDispatchMs: lastDispatchAt ? now - lastDispatchAt : null + }; +} diff --git a/src/main/themes.ts b/src/main/themes.ts new file mode 100644 index 0000000000..0840386c29 --- /dev/null +++ b/src/main/themes.ts @@ -0,0 +1,404 @@ +import { app } from 'electron'; +import { existsSync, readdirSync, statSync, readFileSync } from 'node:fs'; +import { join, isAbsolute } from 'node:path'; +import { sanitizeDisplayName } from '../shared/orchestratorIdentity'; +import { + type ThemeId, + type PackSummary, + type PackPayload, + type Issue, + PACK_ID_RE, + PACK_LIMITS, + validatePack +} from '../shared/themePack'; + +export interface ScanResult { + packs: PackSummary[]; + byPath: Map; +} + +let configuredThemesDir = ''; +/** Folder from Settings → Themes (config.themesDir). Takes effect on the next scan. */ +export function setConfiguredThemesDir(dir: unknown): void { + configuredThemesDir = typeof dir === 'string' ? dir.trim() : ''; + cachedScan = null; +} + +/** + * Returns candidate root directories for theme discovery per §2.6: + * 1. Dev override: env MD_THEMES_DIR (path-delimited list) -> source 'dev' + * 2. User: /themes -> source 'user' + * 2b. Configured: `themesDir` from Settings (set via setConfiguredThemesDir) -> source 'user' + * 3. Built-in: /themes in packaged, or /resources/themes in dev -> source 'builtin' + */ +export function getThemeSearchPaths(): Array<{ dir: string; source: 'builtin' | 'user' | 'dev' }> { + const paths: Array<{ dir: string; source: 'builtin' | 'user' | 'dev' }> = []; + + // 1. Dev override: MD_THEMES_DIR (path-delimited, e.g. ; on Windows, : on POSIX) + const envDir = process.env.MD_THEMES_DIR; + if (envDir && typeof envDir === 'string') { + const delimiter = process.platform === 'win32' ? ';' : ':'; + for (const part of envDir.split(delimiter)) { + const trimmed = part.trim(); + if (trimmed) { + paths.push({ dir: trimmed, source: 'dev' }); + } + } + } + + // 2. User: /themes + const userDataThemes = join(app.getPath('userData'), 'themes'); + paths.push({ dir: userDataThemes, source: 'user' }); + + // 2b. A folder the user chose in Settings, searched after the default user folder. + if (configuredThemesDir && isAbsolute(configuredThemesDir)) { + paths.push({ dir: configuredThemesDir, source: 'user' }); + } + + // 3. Built-in: /themes (packaged) or /resources/themes (dev) + const builtinThemes = app.isPackaged + ? join(process.resourcesPath, 'themes') + : join(app.getAppPath(), 'resources', 'themes'); + paths.push({ dir: builtinThemes, source: 'builtin' }); + + return paths; +} + +/** + * Reads a pack directory from disk, enforcing §2.4 byte caps before returning bytes. + */ +export function readPackFromDisk(dir: string, source: 'builtin' | 'user' | 'dev', id: string): PackPayload { + const files: Record = {}; + const issues: Issue[] = []; + + if (!existsSync(dir)) { + issues.push({ + level: 'error', + code: 'missing-dir', + path: '', + message: `Theme pack directory '${dir}' does not exist` + }); + return { + manifest: { + schema: 1, + id, + name: id, + version: '0.0.0', + author: 'unknown', + license: 'unknown', + map: 'map.tmj', + tilesets: [], + seats: { primary: [], cafe: [], cafeStands: [] }, + anchors: { calendar: { x: 0, y: 0 }, boards: { x: 0, y: 0 }, clock: { x: 0, y: 0 } }, + errandSpots: [], + monitor: { offTopLeftGid: 0, onGids: [] }, + palette: { background: '#000000', noteColors: {} } + }, + files: {}, + source, + issues + }; + } + + // Read all files recursively or up to allowed structure + function collectFiles(currentDir: string, prefix = ''): void { + let entries: string[] = []; + try { + entries = readdirSync(currentDir); + } catch (e) { + issues.push({ + level: 'error', + code: 'read-dir-failed', + path: prefix, + message: `Failed to read directory: ${(e as Error).message}` + }); + return; + } + + for (const entry of entries) { + const full = join(currentDir, entry); + const rel = prefix ? `${prefix}/${entry}` : entry; + try { + const st = statSync(full); + if (st.isDirectory()) { + collectFiles(full, rel); + } else if (st.isFile()) { + // Check file size caps per §2.4 BEFORE loading / adding to payload + if (rel === 'theme.json' && st.size > PACK_LIMITS.manifestBytes) { + issues.push({ + level: 'error', + code: 'manifest-too-large', + path: rel, + message: `theme.json exceeds size limit (${st.size} > ${PACK_LIMITS.manifestBytes})` + }); + continue; + } + if (rel === 'map.tmj' && st.size > PACK_LIMITS.mapBytes) { + issues.push({ + level: 'error', + code: 'map-too-large', + path: rel, + message: `map.tmj exceeds size limit (${st.size} > ${PACK_LIMITS.mapBytes})` + }); + continue; + } + if (rel.startsWith('tilesets/') && st.size > PACK_LIMITS.tilesetBytes) { + issues.push({ + level: 'error', + code: 'tileset-too-large', + path: rel, + message: `${rel} exceeds tileset size limit (${st.size} > ${PACK_LIMITS.tilesetBytes})` + }); + continue; + } + if (rel.startsWith('chrome/fonts/') && st.size > PACK_LIMITS.fontBytes) { + issues.push({ + level: 'warn', + code: 'bad-font', + path: rel, + message: `${rel} exceeds font size limit (${st.size} > ${PACK_LIMITS.fontBytes})` + }); + continue; + } + if (rel === 'chrome/tokens.css' && st.size > PACK_LIMITS.tokensBytes) { + issues.push({ + level: 'warn', + code: 'bad-tokens', + path: rel, + message: `${rel} exceeds tokens size limit (${st.size} > ${PACK_LIMITS.tokensBytes})` + }); + continue; + } + + const buf = readFileSync(full); + files[rel] = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + } + } catch (err) { + issues.push({ + level: 'error', + code: 'read-file-failed', + path: rel, + message: `Failed to read file ${rel}: ${(err as Error).message}` + }); + } + } + } + + collectFiles(dir); + + // Validate pack using pure validator + const validation = validatePack({ dirName: id, files }); + for (const iss of validation.issues) { + issues.push(iss); + } + + let manifest = validation.pack?.manifest; + if (!manifest) { + // Attempt fallback manifest extraction if theme.json exists + try { + if (files['theme.json']) { + const parsed = JSON.parse(new TextDecoder('utf-8').decode(files['theme.json'])); + manifest = parsed; + } + } catch { + // ignore + } + } + + if (!manifest) { + manifest = { + schema: 1, + id, + name: id, + version: '0.0.0', + author: 'unknown', + license: 'unknown', + map: 'map.tmj', + tilesets: [], + seats: { primary: [], cafe: [], cafeStands: [] }, + anchors: { calendar: { x: 0, y: 0 }, boards: { x: 0, y: 0 }, clock: { x: 0, y: 0 } }, + errandSpots: [], + monitor: { offTopLeftGid: 0, onGids: [] }, + palette: { background: '#000000', noteColors: {} } + }; + } + + return { + manifest, + files, + source, + issues + }; +} + +/** + * In-memory scan cache + */ +let cachedScan: ScanResult | null = null; + +/** + * Scans theme search paths per §2.6 discovery order. + * First id wins; duplicate collisions are recorded as unavailable. + */ +export function scanThemes(force = false): ScanResult { + if (cachedScan && !force) { + return cachedScan; + } + + const searchPaths = getThemeSearchPaths(); + const byPath = new Map(); + const summaries: PackSummary[] = []; + const seenIds = new Set(); + + for (const { dir, source } of searchPaths) { + if (!existsSync(dir)) continue; + + let subdirs: string[] = []; + try { + subdirs = readdirSync(dir); + } catch { + continue; + } + + for (const sub of subdirs) { + if (!PACK_ID_RE.test(sub)) continue; + + const packDir = join(dir, sub); + try { + if (!statSync(packDir).isDirectory()) continue; + } catch { + continue; + } + + if (seenIds.has(sub)) { + // Collision: first id wins per §2.6; later one is logged and shown as unavailable + console.warn(`[themes] collision: pack id '${sub}' from ${packDir} (${source}) shadowed by earlier discovery`); + summaries.push({ + id: sub, + name: sub, + version: 'unknown', + source, + available: false, + issues: [ + { + level: 'error', + code: 'id-collision', + path: '', + message: `Theme id '${sub}' collides with an existing theme in higher-priority source` + } + ] + }); + continue; + } + + seenIds.add(sub); + byPath.set(sub, { dir: packDir, source }); + + // Name/blurb/swatch come from a cheap manifest read; the issue list comes from the + // same validator the app loads with, so an "available" badge is truthful. + const themeJsonPath = join(packDir, 'theme.json'); + const issues: Issue[] = []; + let name = sub; + let version = '0.0.0'; + let blurb: string | undefined; + let swatch: string | undefined; + + if (!existsSync(themeJsonPath)) { + issues.push({ + level: 'error', + code: 'missing-manifest', + path: 'theme.json', + message: 'theme.json is missing' + }); + } else { + try { + const st = statSync(themeJsonPath); + if (st.size > PACK_LIMITS.manifestBytes) { + issues.push({ level: 'error', code: 'manifest-too-large', path: 'theme.json', message: 'theme.json exceeds size limit' }); + } else { + const raw = JSON.parse(readFileSync(themeJsonPath, 'utf8')); + if (raw && typeof raw === 'object') { + if (typeof raw.name === 'string') name = raw.name; + if (typeof raw.version === 'string') version = raw.version; + if (typeof raw.blurb === 'string') blurb = raw.blurb; + if (typeof raw.swatch === 'string') swatch = raw.swatch; + } + issues.push(...readPackFromDisk(packDir, source, sub).issues); + } + } catch (e) { + issues.push({ + level: 'error', + code: 'bad-manifest', + path: 'theme.json', + message: `Could not parse theme.json: ${(e as Error).message}` + }); + } + } + + const available = !issues.some((i) => i.level === 'error'); + summaries.push({ + id: sub, + name, + version, + blurb, + swatch, + source, + available, + issues + }); + } + } + + cachedScan = { packs: summaries, byPath }; + return cachedScan; +} + +/** + * Loads a pack by ID. Only loads if `id` is in the scanned set. + */ +export function readPack(id: string): PackPayload | null { + if (!cachedScan) { + scanThemes(); + } + const entry = cachedScan?.byPath.get(id); + if (!entry) { + return null; + } + return readPackFromDisk(entry.dir, entry.source, id); +} + +/** + * Validates a pack by ID. + */ +export function validateThemePack(id: string): { valid: boolean; issues: Issue[] } { + const pack = readPack(id); + if (!pack) { + return { + valid: false, + issues: [ + { + level: 'error', + code: 'not-found', + path: '', + message: `Theme '${id}' not found` + } + ] + }; + } + return { + valid: !pack.issues.some((i) => i.level === 'error'), + issues: pack.issues + }; +} + +/** The orchestrator display name a theme suggests (`copy.orchestratorName`), or null. + * Reads only theme.json, so it is cheap enough for boot and for every config change. */ +export function readThemeOrchestratorName(id: string | undefined): string | null { + if (!id) return null; + try { + const entry = scanThemes().byPath.get(id); + if (!entry) return null; + const manifest = JSON.parse(readFileSync(join(entry.dir, 'theme.json'), 'utf8')); + return sanitizeDisplayName(manifest?.copy?.orchestratorName); + } catch { return null; } +} diff --git a/src/main/transcript.ts b/src/main/transcript.ts index 0d6314088b..c513891e00 100644 --- a/src/main/transcript.ts +++ b/src/main/transcript.ts @@ -129,6 +129,25 @@ export function resolveSessionCwd(sessionId: string): string | null { } } +/** GOD-STARTUP-TOKENS R1: where a session's transcript is (the cwd's project dir first, else any + * project dir, as seedSessionTranscript looks), or null. Read only; nothing is copied. */ +export function sessionTranscriptPath(cwd: string, sessionId: string): string | null { + try { + if (!sessionId || !VALID_SESSION_ID.test(sessionId)) return null; + const own = path.join(projectDir(cwd), `${sessionId}.jsonl`); + if (existsSync(own)) return own; + const projectsRoot = path.join(os.homedir(), '.claude/projects'); + if (!existsSync(projectsRoot)) return null; + for (const dir of readdirSync(projectsRoot)) { + const candidate = path.join(projectsRoot, dir, `${sessionId}.jsonl`); + if (existsSync(candidate)) return candidate; + } + return null; + } catch { + return null; + } +} + export interface AgentUsage { inputTokens: number; outputTokens: number; diff --git a/src/main/updater.ts b/src/main/updater.ts index f33835b7bc..bc3026d042 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -44,7 +44,7 @@ import { reduceStatus, clampPercent, isNewer, installerUrl, shouldShowReleaseDro * downgrade is per-check, not a permanent latch. */ -const REPO = 'chaitanyagiri/munder-difflin'; +const REPO = 'jdell64/agent-stage'; const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h const FALLBACK_CACHE_MS = 60 * 60 * 1000; // 1h between releases/latest polls @@ -346,7 +346,7 @@ async function runDownload(): Promise<{ ok: boolean; error?: string }> { const SIMULATED_NOTES = `# Munder Difflin v9.9.9 **A local hive of Claude Code, Antigravity, Codex, Grok & Copilot agents that run themselves** — -messaging, routing, and remembering, coordinated by your clone, Michael, who you talk to. +messaging, routing, and remembering, coordinated by your clone, the orchestrator, who you talk to. --- diff --git a/src/main/usageEndpoint.ts b/src/main/usageEndpoint.ts new file mode 100644 index 0000000000..e8956a5d86 --- /dev/null +++ b/src/main/usageEndpoint.ts @@ -0,0 +1,62 @@ +/** + * Fetch the user's provider-usage endpoint. Never fatal: every failure comes back + * as `{ ok: false, error }` so a down Raspberry Pi can never block a standup. + * + * Deliberately not `fetchText.getText`: that helper is https-only with a fixed + * user agent, but this endpoint usually lives on a LAN box (plain http) and may + * need a bearer token. Redirects are refused so a token is never replayed to a + * host the user did not configure. + */ +import { + parseUsageResponse, validateUsageUrl, DEFAULT_USAGE_TIMEOUT_MS, + type UsageSnapshot, type UsageEndpointConfig +} from '../shared/usageEndpoint'; + +const MAX_BODY_BYTES = 256 * 1024; + +export type UsageFetchResult = + | { ok: true; snapshot: UsageSnapshot } + | { ok: false; error: string }; + +export async function fetchUsage(cfg: Pick): Promise { + const url = validateUsageUrl(cfg.url); + if (!url) return { ok: false, error: 'not an http(s) URL' }; + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), cfg.timeoutMs > 0 ? cfg.timeoutMs : DEFAULT_USAGE_TIMEOUT_MS); + try { + const headers: Record = { accept: 'application/json' }; + if (cfg.token) headers.authorization = `Bearer ${cfg.token}`; + const res = await fetch(url, { headers, redirect: 'manual', signal: ac.signal }); + if (res.status >= 300 && res.status < 400) return { ok: false, error: `redirect (${res.status}) refused` }; + if (!res.ok) return { ok: false, error: `HTTP ${res.status}` }; + const declared = Number(res.headers.get('content-length') ?? 0); + if (declared > MAX_BODY_BYTES) return { ok: false, error: 'response too large' }; + const text = await readCapped(res); + if (text === null) return { ok: false, error: 'response too large' }; + let json: unknown; + try { json = JSON.parse(text); } catch { return { ok: false, error: 'not JSON' }; } + const snapshot = parseUsageResponse(json); + return snapshot ? { ok: true, snapshot } : { ok: false, error: 'no usable agents in response' }; + } catch (e) { + const aborted = e instanceof Error && e.name === 'AbortError'; + return { ok: false, error: aborted ? 'timed out' : e instanceof Error ? e.message : String(e) }; + } finally { + clearTimeout(timer); + } +} + +/** Read the body but give up once it passes the cap (a chunked response can lie about its length). */ +async function readCapped(res: Response): Promise { + if (!res.body) return res.text(); + const reader = res.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > MAX_BODY_BYTES) { await reader.cancel(); return null; } + chunks.push(value); + } + return Buffer.concat(chunks).toString('utf8'); +} diff --git a/src/main/webhook.ts b/src/main/webhook.ts index 01346f3575..8bbed3a534 100644 --- a/src/main/webhook.ts +++ b/src/main/webhook.ts @@ -7,7 +7,7 @@ * - POST / + `x-md-webhook-secret: ` * + JSON body matching THAT endpoint's user-editable schema * → 200 `{ ok, token, taskId }` when the endpoint's TriggerMode lets the - * message through (routed to god, kanban card created), or + * message through (routed to orchestrator, kanban card created), or * → 202 `{ ok, pending: true, token, status: 'awaiting-approval' }` when the * mode holds it for the operator. Either way the caller gets its token. * - GET / + `x-md-webhook-token: ` (or `?token=`) @@ -35,7 +35,7 @@ * * Runs in the Electron main process. Deliberately free of any `electron` import so * it can be unit-/smoke-tested as a plain Node module. The actual card creation + - * god routing + token→status lookup are injected as callbacks (they need hive + * orchestrator routing + token→status lookup are injected as callbacks (they need hive * access, which lives in the main entrypoint); this class owns only transport, * the secret gate, schema validation, rate limiting, and the tunnel. */ @@ -87,7 +87,7 @@ export interface WebhookDispatch { token: string; /** Kanban card id; absent while a message waits for the operator (no card yet). */ taskId?: string; - /** true = held for operator approval (→ 202), false = routed to god (→ 200). */ + /** true = held for operator approval (→ 202), false = routed to orchestrator (→ 200). */ pending: boolean; } diff --git a/src/main/workerLaunch.ts b/src/main/workerLaunch.ts index fc12f6b76d..8c299bc084 100644 --- a/src/main/workerLaunch.ts +++ b/src/main/workerLaunch.ts @@ -1,5 +1,5 @@ /** - * How a god-hired worker's spawn request becomes an executable + argv, as a + * How a orchestrator-hired worker's spawn request becomes an executable + argv, as a * pure function: this exact translation silently killed real workers for days * while reporting success, which is what earned it a unit test. */ @@ -22,7 +22,7 @@ export interface WorkerLaunch { } export function buildWorkerLaunch(opts: { - /** `command` from the spawn request — god authors a full command LINE. */ + /** `command` from the spawn request — orchestrator authors a full command LINE. */ requestCommand?: unknown; requestProvider?: unknown; /** Separate `model` field from the request, if any. */ @@ -57,7 +57,7 @@ export function buildWorkerLaunch(opts: { if (autoFlag && !hasAutoModeStance(tokenizeCommand(command), provider)) { command += ` ${autoFlag}`; } - // god authors `command` as a full command LINE ("claude --model … --permission-mode …"), + // orchestrator authors `command` as a full command LINE ("claude --model … --permission-mode …"), // but the PTY layer takes ONE executable name (resolveCommand) plus argv — the // unsplit line made node-pty exec a binary literally named like the whole // string → ENOENT → the worker died within ~1s of spawning while its request diff --git a/src/main/workerWake.ts b/src/main/workerWake.ts index b505b02004..0c15b90e7e 100644 --- a/src/main/workerWake.ts +++ b/src/main/workerWake.ts @@ -5,7 +5,7 @@ * path for a worker that has gone quiet at its prompt: it polls on a setInterval * in the renderer, so a throttled/occluded window (Chromium suspends background * setInterval timers) can miss the moment mail lands and the worker then sits on - * an undrained inbox forever — the orchestrator ("god") never has this problem + * an undrained inbox forever — the orchestrator ("orchestrator") never has this problem * because the main process re-engages it on its own heartbeat cadence. * * This watchdog is the worker-side counterpart: on a cadence it finds live @@ -34,7 +34,7 @@ /** The exact nudge the renderer's inbox-wake loop would have typed. */ export const WORKER_WAKE_NUDGE = - 'You have new hive inbox message(s) — read your inbox, act on them now, and move handled ones to inbox/.done/. Act autonomously; only message god if you genuinely need a decision.'; + 'You have new hive inbox message(s) — read your inbox, act on them now, and move handled ones to inbox/.done/. Act autonomously; only message orchestrator if you genuinely need a decision.'; /** No PTY output for this long = genuinely idle (renderer QUIESCE_IDLE_MS). */ export const WORKER_WAKE_IDLE_MS = 12_000; @@ -113,10 +113,10 @@ export function isTurnHook(event: string | undefined): boolean { /** One worker's live facts, gathered by the caller each beat. */ export interface WorkerWakeFacts { - /** Worker agent id (god is never a candidate). */ + /** Worker agent id (orchestrator is never a candidate). */ agentId: string; - /** True when this agent is the orchestrator — god is never nudged. */ - isGod?: boolean; + /** True when this agent is the orchestrator — orchestrator is never nudged. */ + isOrchestrator?: boolean; /** Live PTY id, or undefined when the agent has no terminal. */ ptyId?: string; /** Timestamp of the PTY's last output (0 = never output). */ @@ -142,7 +142,7 @@ export interface WorkerWakeFacts { /** Why a worker with pending mail is NOT being nudged right now. */ export type WorkerWakeHold = - | 'god' | 'no-mail' | 'no-pty' + | 'orchestrator' | 'no-mail' | 'no-pty' | 'delivery-paused' | 'paused' | 'halted' | 'booting' | 'mid-turn' | 'boot-grace' | 'hitl' | 'announced' | 'cooldown'; @@ -254,7 +254,7 @@ export class WorkerWakeWatchdog { explain(f: WorkerWakeFacts, now = Date.now()): WorkerWakeHold | null { const inboxIds = liveInboxIds(f); if (inboxIds.size === 0) return 'no-mail'; - if (f.isGod) return 'god'; + if (f.isOrchestrator) return 'orchestrator'; if (!f.ptyId) return 'no-pty'; if (f.autoDeliveryPaused) return 'delivery-paused'; if (f.paused) return 'paused'; diff --git a/src/preload/index.ts b/src/preload/index.ts index ffdb5382a7..7b38ed0817 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -12,6 +12,8 @@ import type { HeroPayload } from '../shared/heroPayload'; export type { HeroPayload } from '../shared/heroPayload'; import type { ModelCatalog } from '../shared/modelCatalogPayload'; export type { ModelCatalog, CatalogModel } from '../shared/modelCatalogPayload'; +import type { ModelsCatalog, ModelsRefreshRow } from '../shared/modelCatalog'; +export type { ModelsCatalog, ModelsRefreshRow } from '../shared/modelCatalog'; import type { HookEvent } from '../shared/hookEvents'; export type { HookEvent } from '../shared/hookEvents'; import type { LocalSkill, CatalogSkill } from '../main/skills'; @@ -22,6 +24,8 @@ import type { export type { ContextRule, ContextTriggerConfig, OrgTriggerConfig, TriggerHistoryEntry, WebhookTrigger } from '../shared/triggers'; +import type { PackSummary, PackPayload, Issue } from '../shared/themePack'; +export type { PackSummary, PackPayload, Issue } from '../shared/themePack'; /** Renderer-visible integration record: the secretRef handle is redacted to a * presence boolean. Matches main `integrations.listRecordsRedacted()` — the @@ -53,8 +57,8 @@ export interface HiveAgentMeta { role?: string; capabilities?: string[]; cwd: string; - isGod?: boolean; - /** Michael's prep assistant — send-only; enriches prompts and forwards them. */ + isOrchestrator?: boolean; + /** the orchestrator prep assistant — send-only; enriches prompts and forwards them. */ isAssistant?: boolean; } @@ -93,7 +97,7 @@ export interface VoiceMessage { } export interface HiveRegistry { - godId: string | null; + orchestratorId: string | null; /** `archived` agents have had their terminal closed — retained + flagged, not * deleted; only live-PTY agents are 'active'. */ agents: Record => ipcRenderer.invoke('hive:renameAgent', id, name), /** Put an agent on hold (the human has them 1:1) or take it off. Held agents - * keep running; Michael is told to stop routing work to them. */ + * keep running; the orchestrator is told to stop routing work to them. */ hiveSetAgentHold: (id: string, hold: boolean): Promise<{ ok: boolean; onHold?: boolean; error?: string }> => ipcRenderer.invoke('hive:setAgentHold', id, hold), hiveBoard: (): Promise => ipcRenderer.invoke('hive:board'), @@ -766,12 +772,12 @@ const api = { hiveInbox: (id: string): Promise => ipcRenderer.invoke('hive:inbox', id), /** Voice read-layer: recent message CONTENT (inbox/outbox bodies), REDACTED in * main. Pass { id } for one message, { agentId } to scope to one mailbox, or - * {} for the whole floor. Backs Realtime Michael's get_messages. The renderer + * {} for the whole floor. Backs Realtime voice's get_messages. The renderer * never sees a raw body or a secret — stripping happens main-side. */ hiveMessages: (opts?: { agentId?: string; id?: string; limit?: number; includeArchived?: boolean }): Promise => ipcRenderer.invoke('hive:messages', opts ?? {}), /** Consolidated per-agent directory (registry + telemetry + context), incl. - * archived agents. Backs Realtime Michael's get_agent_detail / list_agents. */ + * archived agents. Backs Realtime voice's get_agent_detail / list_agents. */ hiveAgentDirectory: (): Promise => ipcRenderer.invoke('hive:agentDirectory'), // ─── Ephemeral workers (P4 — Slack-triggered isolated workers) ─────────── @@ -790,12 +796,15 @@ const api = { /** Settings hero payload — plan + sponsor, fetched from the repo and cached. */ heroPayload: (force?: boolean): Promise<{ hero: HeroPayload; fetchedAt: number; stale: boolean }> => ipcRenderer.invoke('hero:payload', force), - /** The remote model catalog — the agent model presets, fetched from the repo - * and cached, so a new model needs a JSON edit rather than a release. A null - * catalog means the renderer keeps the list compiled into the build. */ - modelCatalog: (force?: boolean): Promise<{ - catalog: ModelCatalog | null; fetchedAt: number; stale: boolean; - }> => ipcRenderer.invoke('models:catalog', force), + /** REFRESH-MODELS: the models file (userData/models.json), or null. A file read, never a lookup. */ + modelCatalog: (): Promise => ipcRenderer.invoke('models:catalog'), + /** REFRESH-MODELS: the Settings button. Queries every provider, rewrites the file, returns the per-provider rows. */ + refreshModels: (): Promise<{ file: ModelsCatalog; rows: ModelsRefreshRow[] }> => ipcRenderer.invoke('models:refresh'), + onModelCatalogChanged: (cb: (file: ModelsCatalog) => void): (() => void) => { + const listener = (_e: IpcRendererEvent, file: ModelsCatalog) => cb(file); + ipcRenderer.on('models:catalogChanged', listener); + return () => ipcRenderer.removeListener('models:catalogChanged', listener); + }, /** Skills already installed for the coding agents on this machine. */ skillsLocal: (cwd?: string): Promise => ipcRenderer.invoke('skills:local', cwd), /** The browsable skills catalog (cached; `force` re-fetches). */ @@ -983,9 +992,9 @@ const api = { newFloor: (): Promise<{ ok: boolean }> => ipcRenderer.invoke('window:newFloor'), // ─── Closing time (graceful shutdown via the hive) ───────────────────────── - /** Start the closing-time protocol: the god broadcasts shutdown, every worker - * saves its memory and ACKs, the god concludes — then the app quits itself. - * Resolves with ok:false (+ error) when no god agent is running. */ + /** Start the closing-time protocol: the orchestrator broadcasts shutdown, every worker + * saves its memory and ACKs, the orchestrator concludes — then the app quits itself. + * Resolves with ok:false (+ error) when no orchestrator is running. */ startClosingTime: (): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('app:startClosingTime'), /** Abort an in-progress closing time and tell the floor to resume work. */ @@ -1140,7 +1149,7 @@ const api = { hiveSetArchived: (id: string, archived: boolean): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('hive:setArchived', id, archived), - // ─── Slack integration (Slack message → Michael's queue) ───────────────────── + // ─── Slack integration (Slack message → the orchestrator's queue) ───────────────────── /** Register a listener for inbound Slack messages; returns an unsubscribe fn. * The message carries the thread coordinates needed to reply in-thread. */ onSlackMessage: (cb: (msg: { text: string; channel: string; ts: string; thread_ts: string; autonomyPreamble?: string; files?: { path: string; name: string; mimetype: string }[] }) => void): (() => void) => { @@ -1241,7 +1250,7 @@ const api = { /** The whole ledger, newest first (both directions, both sources). */ listTriggerHistory: (): Promise => ipcRenderer.invoke('triggerHistory:list'), - /** Answer a held message. 'approved' RELEASES it to the hive (card + god + /** Answer a held message. 'approved' RELEASES it to the hive (card + orchestrator * request, the same path an auto-allowed message takes); 'rejected' only flips * the verdict. Deciding an already-decided entry is a no-op, never a second * dispatch. Resolves to the updated row, or null when the id is gone. */ @@ -1299,7 +1308,7 @@ const api = { ipcRenderer.invoke('providerKey:has', backend), providerKeyClear: (backend: string): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('providerKey:clear', backend), - // Realtime Michael (voice orchestrator) — MAIN mints a short-lived EPHEMERAL token + // Realtime voice (voice orchestrator) — MAIN mints a short-lived EPHEMERAL token // from the BYOK OpenAI key; the real key NEVER crosses IPC. `realtimeHasOpenAiKey` // is a presence boolean only (gates the voice toggle, like providerKeyHas). realtimeHasOpenAiKey: (): Promise => @@ -1311,7 +1320,7 @@ const api = { | { ok: false; error: string; code?: string } > => ipcRenderer.invoke('realtime:mintToken', req ?? {}), // rt-5 voice ACTIONS — the renderer holds NO policy; main (realtimeActions.ts) owns - // the tiering, two-step verbal confirm, hard allowlist, and michael-voice + // the tiering, two-step verbal confirm, hard allowlist, and orchestrator-voice // attribution. These just forward {verb,...args} and speak back `spoken`. realtimeAction: ( payload: { verb: string } & Record @@ -1324,7 +1333,7 @@ const api = { realtimeActionCancel: (): Promise<{ ok: boolean; spoken: string; needsConfirm?: boolean }> => ipcRenderer.invoke('realtime:action:cancel'), // rt-12 completion seam — a voice-dispatched task finished. `summary` is the - // human-speakable line Michael relays; the rest is context for a toast/log. + // human-speakable line the orchestrator relays; the rest is context for a toast/log. onRealtimeCompletion: ( cb: (evt: { correlationId: string; kind: string; targetAgentId: string; taskId?: string; summary: string; completedAt: number; objective?: string }) => void ): (() => void) => { @@ -1423,7 +1432,19 @@ const api = { notes?: string; /** Preview the centered release page using the default drop template. */ drop?: boolean; - }): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('update:simulate', opts) + }): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('update:simulate', opts), + + // Themes (§2.7) + listThemes: (): Promise => ipcRenderer.invoke('themes:list'), + rescanThemes: (): Promise => ipcRenderer.invoke('themes:rescan'), + loadThemePack: (id: string): Promise => ipcRenderer.invoke('themes:load', id), + validateTheme: (id: string): Promise<{ valid: boolean; issues: Issue[] }> => ipcRenderer.invoke('themes:validate', id), + openThemesFolder: (): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('themes:openFolder'), + themeOrchestratorName: (): Promise => ipcRenderer.invoke('themes:orchestratorName'), + + // Provider-usage endpoint (Settings "Test") + testUsageEndpoint: (cfg: { url: string; token?: string; timeoutMs?: number }): Promise<{ ok: boolean; summary?: string; error?: string }> => + ipcRenderer.invoke('usage:test', cfg) }; contextBridge.exposeInMainWorld('cth', api); diff --git a/src/renderer/index.html b/src/renderer/index.html index 39448a9195..b1642095e9 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -2,7 +2,7 @@ - Munder Difflin + Agent Stage