The Document Center only read the `documents` table, which is prose
content POSTed via /api/documents. Everything the agents wrote via
`bash` / `write` / `edit` — the bulk of their output on a real
project — landed in `work_products` and never appeared on the page.
Operators saw "0 items" after a full night's run.
Lift work_products into the same UI:
- `listWorkProductsForCompany` returns a newest-first slice with a
500-row cap so the snapshot stays bounded.
- `ProfileSnapshot.workProducts` flows through `useCompanyData` as
`companyWorkProducts`, filtered to the current company.
- `DocumentCenter` renders two labelled sections in order — prose
documents (editable) on top, files & deliverables below. Each
work-product row opens the underlying URL or filesystem path
when clickable, so the agent's bash-generated markdown, PRs,
and artifacts are one click away.
`WorkProductRecord` lifts into `@shared/types/records-extended` so
the renderer, snapshot, and database shells all reference the same
shape. The store-test fixture learns about the new field.
Previous Task 2 pass gated every destructive tool on any blocking
approval or any open wait-state for the agent. That was too coarse:
a pending deliverable_review or approve_ceo_strategy (both decision-
type approvals, waiting for a human to respond to completed or
proposed work) froze the agent's entire toolchain — including the
`bash` call the agent uses to POST /api/messages. Result: CEO
talked, employees woke on heartbeat, tried to reply, the message
POST was blocked as a "destructive tool", the run ended silent.
Every reply collapsed to an empty heartbeat.
Two corrections land together:
1. Approval gate narrows to the five approval types that actually
gate EXECUTION — `dangerous_command`, `secret_access`,
`production_deploy`, `social_post`, `budget_override`. Review /
decision types are ignored at the tool layer; they remain visible
in the board UI and still block via the heartbeat scheduler when
the execution mode demands it.
2. Wait-state check leaves the hook entirely. The heartbeat scheduler
(`checkpoint-engine.shouldPauseHeartbeat`) is the single owner of
"agent should pause because a thread is waiting" — mirroring the
rule at tool level produced the same company-wide freeze for
any reply on any thread. Heartbeat is the right scope; tools
are not.
The destructive-tool set and the operational-approval set are both
named, readonly, and centralised so the next addition reads like a
whitelist instead of a patch.
The session_entries supersede pipeline was a design that never
shipped: `SqliteSessionStore.replaceMessages` / `appendCompaction`,
`sessionEntries.replace` / `listAll` / `deleteForSession`, and the
`"compaction" | "summary"` kind variants had zero live callers.
Compaction wires through pi's `transformContext` and leaves the
persisted transcript alone, so the shadow-row machinery was pure
cruft with two tests propping it up.
Rip it out: the store exposes only `loadMessages` and
`appendMessage`, `sessionEntries` exposes only `append` and
`listActive`, and `SessionEntryKind` narrows to `"message"` so new
writers can't re-introduce a dead kind by accident.
`is_superseded` stays on the table — the DB column is harmless
with no writer, and dropping it is a separate schema migration
concern.
`listTopLevelAgentIds` loses its only caller in the previous
commit (company channel now fans out through
`listCompanyBroadcastAgentIds`), so it goes too. Three tests that
validated the removed behaviour are deleted; the one remaining
session-store test still covers the live `append + load` path.
README described target-state features that the previous migration
left half-implemented. Now that the runtime actually matches the
promise, the docs can stop overselling: agent messaging is
described as the real live-steer + inbox flow, approvals as the
beforeToolCall gate that actually blocks destructive tools,
sessions as the transformContext-driven compaction plus rotation.
The architecture bullet list grows to name the seams that landed
across this migration — the pi contract wiring, the approval gate,
the workspace lock model, and the stripped startup mutations — so
the README reads like documentation rather than roadmap.
The filesystem prompt doc (`files.en.md`) adds the missing
preamble: every bash/write/edit succeeds into work_products
automatically, and the API endpoints below are only needed for
non-filesystem deliverables. Agents were routinely posting
duplicate work products because nothing told them the projection
already ran.
One flat list of `status = in_review` tasks couldn't tell the
operator whether the next decision belonged to the board (approval
pending), to the human user (top-level assignee or explicit
`requires_user_review`), or to a middle manager walking up the
`reports_to` chain. Three very different escalation paths rendered
identically, so "reviewed by X" was the only signal and operators
missed the ones actually waiting on them.
`PendingReviewList` now classifies each in-review task into one of
three kinds (`board` / `user` / `manager`) using the pending
approval set plus the existing assignee/manager lookup. Rendering
groups them under labelled sub-headings in priority order (board
first — the operator is the bottleneck there), each with its own
awaiting-text so the row's status is unambiguous at a glance.
The component accepts approvals via prop; the caller passes the
existing `pendingApprovals` it already holds. Translations for both
locales carry the new group labels.
"Company" channel fell through to `listTopLevelAgentIds`, so a
broadcast on the most ambient-sounding channel in the product only
reached agents with `reports_to IS NULL`. The label said company;
the semantics said board-only. The rest of the org never knew a
message existed.
Add `listCompanyBroadcastAgentIds` — returns every non-terminated,
non-paused, non-pending agent for the company — and wire it through
the `company` case in `resolveMessageWakeTargets`. Board-only
fan-outs are still reachable: the `incident` channel keeps the
broader pingable set (paused-by-budget included) and direct DMs to
top-level agents work as before.
The prompt doc catches up to the runtime: company is explicitly
framed as all-hands, with board-only guidance redirected to DMs or
incidents. The message-dispatch unit test asserts the new fan-out.
The thread-panel composer was pinned to `channel="direct"` with no
`directParticipantIds` and no `replyTo`. Clicking reply in a human
review thread sent a message with no recipient and the send button
stayed disabled — a silent dead-end for the entire Co-Founder
review UX.
The fix exposes the thread's participant list to the renderer:
- `listThreadParticipantsSchema` and the matching IPC channel
(`agent-company:list-thread-participants`) return the rows from
`conversation_participants` for a given thread.
- `threadIntegration.onTaskCreated` / `onApprovalCreated` already
enrol the owning agent and the virtual user on thread creation,
so the list is populated without any backfill.
- `ThreadPanel` loads participants alongside events and forwards
the agent ids to `MessageComposer.directParticipantIds`. The
composer's existing `directRecipientId` memo picks the first
non-board agent, so replies now target the correct agent — or
surface the "no recipient" notice when the thread has none.
The Board/user member is deliberately excluded from the list the
composer consumes: the composer wants an agent to route to. All
other wiring (wait-state precision, threadId) stays untouched.
pi's `transformContext` hook fired on every LLM call but returned
the transcript unchanged, so a long-running agent paid per-token
for its entire history on every turn. The README promised "sessions
persist + auto-compact" but the main loop had no compaction wired
in at all — only rotation at the long-cycle boundary.
`context-compactor` closes that gap. A pure, provider-free function
estimates tokens (~4 chars/token), short-circuits below a 120K
threshold, and on overflow keeps the last 20 messages verbatim
while collapsing older ones into a single "context summary" user
turn. The summary preserves the original task and the first twelve
assistant highlights so the agent has continuity without forwarding
every historical turn.
Compaction is non-destructive: the Agent's in-memory transcript
and the SQLite session store still hold the full history, feeding
UI timelines and audit. Only the LLM payload shrinks. Rotation
remains the long-cycle boundary (separate commit) — compaction is
what keeps each individual LLM call within budget.
`init()` ran three implicit data-repair queries on every boot:
`reconcileOrphanedTasks`, `reconcileUnreviewableTasks`, and a
secrets-table migration whose fallback glued legacy rows onto a
synthetic zero UUID when no current company existed. The first
two papered over state-machine leaks at init time — hiding the
real bug and making startup observably mutate production data;
the third silently connected ciphertext to a non-existent company
with no UI visibility and no deletion path.
Root fix: the startup path is now allowed exactly one mutation —
`interruptStaleRuns`, which is crash recovery (running-at-boot =
worker died mid-turn) and cannot be expressed anywhere else.
The dead-letter `in_review` case moves to the transition point:
`tasks-status.guardInReviewHasReviewer` refuses to advance a task
into `in_review` when its assignee is a top-level agent
(`reports_to IS NULL`) and the task is not flagged for user
review. Callers either pre-flag the task (the CEO-completion path
in `automation-run-completion.advanceToInReview` now does this)
or the transition is rejected. No more silent resurrection at
boot; the failure is visible and actionable at source.
The orphan-task reconcile disappears entirely — the `finishRun`
guard already enforces the invariant at the one place a run can
leave the in-progress state, and `recoverStaleLocks` handles
stuck executions at runtime.
The secrets migration now throws when neither
`settings.current_company_id` nor any company row exists, rather
than binding legacy ciphertext to `00000000-0000-0000-0000-000000000000`.
Legacy rows stay on the old table until the operator creates a
real company, at which point the upgrade succeeds deterministically.
Tests rewritten: the reconciliation regression suite now asserts
the transition-time guard and the lone `interruptStaleRuns`
behavior. `resolve-workspace.test.ts` catches up with the
exclusive-by-default lock policy committed earlier.
`pickModelForAgent` used to fall back to the CEO's pair when the
auto-strategy scoring returned no winner, and then silently route
the run on that pair. Three distinct problems rendered invisible by
that fallback: a missing capability row for a deployed model, a
mis-inferred task profile, a misconfigured model pool. Cost
attribution became meaningless — the agent ran on the CEO's model,
not its own tier — and operators had no signal to fix the root
cause.
The picker now returns a `ModelPickResult` tagged union. Success
yields the dispatch-ready assignment; failure yields a
`NO_QUALIFYING_CANDIDATE` reason with a human-readable message
naming the agent, the required domain, and the required tier.
Heartbeat maps the failure to a new `model_pick_failed` skip
reason, logs an activity row so it shows up in the company audit
trail, and defers. Task-runner returns `fail("MODEL_PICK_FAILED",
...)` so the UI surfaces the message directly. `getCeoAssignment`
drops out of `PickModelDeps` entirely — no caller needs a silent
CEO path any more, so the dep surface shrinks and the tests do
too. The test suite gains a regression assertion for the explicit
failure case.
`accumulateAndRotateSession` called `evaluateRotation` on the
pre-accumulation `session` object, then re-read the row only
inside the shouldRotate branch. A run that tipped the token
threshold on its own usage would read "below threshold" on this
cycle and wait a full additional run before rotating — long
enough for the transcript to keep growing past the point where
rotation was supposed to protect the context window.
Read once after accumulation, evaluate against the fresh totals,
rotate from the same object. No new queries, no new branches — the
failure mode is just an off-by-one-run bug in the original
read-eval-rotate sequence.
agent-, project-, and company-fallback workspaces resolved to
`shared`, so four agents sharing one repo wrote concurrently and
corrupted both the working tree and git's own index state before
the per-file `index.lock` even got a chance to kick in. The
previous comment justified this as "the normal case", but pi's
`createCodingTools()` hands every agent bash/write/edit — there is
no read-only category left where shared is safe.
This promotes every resolved workspace to `exclusive`. Contention
is still handled at the one place it must be: `run-dispatch.ts`
synchronously claims the workspace lock, and `finishRun`'s
never-started branch rolls the task back to `todo` on a lost
race. The "shared mode prevents deadlocks" story was really
"shared mode prevents one specific bug in the checkout rollback
path that has since been fixed"; the right invariant is single
writer per repo, enforced by the DB lock. Concurrent heartbeats
for the same workspace now queue through the wakeup table on the
next cycle rather than racing the filesystem.
The artifact projector only registered write/edit. Everything bash
produced — build artifacts, files written via shell redirection,
git commits, generated code — stayed invisible to the desktop UI
and the audit layer. Filesystem truth and UI truth were
systematically out of sync.
This lifts bash onto the same filesystem artifact protocol as
write/edit. When a bash tool call succeeds in a git workspace, the
projector runs `git status --porcelain=v1 -z --untracked-files=all`
and upserts a work_products row for every modified/untracked entry,
reusing the existing `(companyId, provider='filesystem', externalId)`
identity so repeated edits never duplicate. Non-git workspaces fall
through with an explicit "not_a_git_repo" skip reason — there is no
authoritative baseline there to diff against, and silently
half-registering would be worse than skipping.
A 50-file cap per invocation bounds the scan so a runaway rebuild
cannot flood the table; anything past the cap is logged as a skip.
Rename entries (emitted as `R new\0old`) advance the iterator past
the old-path entry so deletions of the rename source are not
double-registered.
The projector now always returns an array so `write`/`edit` (one
path) and `bash` (many paths) share one shape, and the hook handler
can log uniform outcomes.
.gitignore gains `AgentCompany-Rust/` — the separate Rust rewrite
tree lives in the same repo root but is not part of the desktop app
build and should not drift into feature commits.
Incoming messages (human → agent, agent → agent, channel broadcast)
persisted to the inbox and were only projected into the recipient's
transcript on the next `prepareRun`. When the recipient was already
running, every message waited for the next wake — a Slack-like
system operating at heartbeat cadence, not in real time.
`RuntimeMessageInjector` closes the gap: it looks up the recipient's
active run through `RuntimeBridge.findActiveRunForAgent`, projects
the persisted `AgentMessageRecord` via the existing inbox-projection
helper, and posts a `steer-run` ParentMessage into the worker. The
worker's runner enqueues the projection on the Agent's steering
queue; pi drains it at the next turn boundary and emits the usual
`message_end` → `persistMessage` sequence, so the session store
shadow is preserved without double-writing.
`dispatchAgentMessage` gains an optional `injectRuntimeMessage` hook
used by every API-side dispatch. Injection succeeds → we mark the
inbox row delivered and skip `wakeAgentIfPossible`. Injection fails
(no active run) → the legacy inbox + wake path still fires, so
sleeping agents continue to receive their messages on the next turn.
The injector is owned by the service graph alongside `workerService`
and `piRuntimeRef`, which gives it both the worker postMessage
channel and the RuntimeBridge active-run map without additional
coupling. Internal dispatch call sites (sweepStaleReviews,
automation actions) leave the hook unset; those system messages
target idle agents by design and do not need the runtime path.
Approval creation already flowed through the API server, but pi's
`bash` / `write` / `edit` tools never traversed that layer, so the
gate was purely advisory: the agent kept mutating the workspace while
the board tried to hold it. The fix: policy runs at the one place
every destructive tool invocation must pass, pi's `beforeToolCall`.
`checkApprovalBlock` fails closed on lookup errors — an unreadable
approvals table is a safety incident, not a licence to execute. It
re-reads `getBlockingApproval` on every call so an approval created
mid-run takes effect on the next tool without any cache.
`checkWaitStateBlock` rides the unified
`hasUnresolvedWaitStateForAgent` query so task, approval, and
freeform thread wait states all gate the same tools. Read-only tools
are untouched; only bash/write/edit are gated because gating
observation would slow investigation without adding safety.
The destructive-tool set is promoted to a named predicate so the
Task 5 artifact projector can share the same definition when it
extends to bash outputs.
`hasUnresolvedWaitStatesForAgent` only joined through tasks, so
approval-only threads and ad-hoc threads where the agent is the
requesting party or an explicit participant were invisible. Result:
the heartbeat scheduler silently resumed agents that the board had
asked to wait on, Co-Founder approval chains deadlocked on the
agent side, and any new gate that relies on the query inherits the
same hole.
Root-fix the query at the SQL layer. `hasUnresolvedWaitStateForAgent`
now returns true when any of the three ownership predicates hold:
- task thread whose task.assignee_agent_id = agent
- approval thread whose approval.requested_by_agent_id = agent
- any thread on which the agent is a recorded participant
Each branch is an EXISTS subquery so the query short-circuits on
first match. The thread-integration helper delegates here so the
heartbeat scheduler and the `beforeToolCall` gate share one source
of truth rather than reimplementing half-policies in parallel.
The worker previously constructed `new Agent({systemPrompt, model, tools,
messages, streamFn})` and stopped there — transformContext,
beforeToolCall, afterToolCall, sessionId, thinkingLevel, and steer/
followUp were all dark. Approval enforcement, artifact projection,
auto-compaction, and mid-run steering all had to reach the agent
through other, non-canonical channels (event observers, prompt-block
injection at prepareRun, API-layer gates). That left the whole pi
contract at one-tenth utilisation and forced business logic into the
wrong seams.
This change rebuilds the contract assembly layer:
- `pi-agent-factory` builds the Agent with the full option surface:
sessionId (taskSessionId), thinkingLevel (mapped from
agent.metadataJson), and three RPC-proxied hooks.
- Each hook shuttles a thin payload over the existing worker RPC
channel into `RuntimeBridge`, which routes to `hook-handlers`:
* beforeToolCall → approval-gate policy (blocks destructive
tools while a pending approval is outstanding).
* afterToolCall → filesystem artifact projection, previously
nested in the tool_execution_end event observer.
* transformContext → pass-through today; compaction wires in
next commit.
- ParentMessage gains `steer-run` / `followup-run`; `WorkerRunner`
keeps a runId→Agent registry so main can inject mid-run messages
into the active pi loop.
`worker-types` extends the rpc-request method enum; `runtime-bridge`
loses the per-event artifact projector and gains a `findActiveRunForAgent`
lookup used by the upcoming inbox → steer path. No public main API
changes yet — the wiring is the foundation for the next five commits.
Full-codebase ts-prune + manual audit sweep. Every change below is a pure
deletion (or a 2-line re-wire where a dead function was supposed to be
alive). No scope additions.
Dead wrappers / phantom features
- shouldAutoApproveApproval: zero-value wrapper over shouldAutoApprove.
Four call sites now import shouldAutoApprove from execution-policy
directly.
- evaluateRotation(session, connectorId, thresholdsOverride): the
connectorId arg was never read (underscored), and thresholdsOverride
was only exercised by the test suite — no production caller. Signature
collapses to evaluateRotation(session). DEFAULT_THRESHOLDS /
RotationThresholds exports and their tests go with it.
- installLogRedactor: defensive console wrapper that was never wired
from worker-entry. buildRedactor handles the known threat (env-value
leaks); the pattern-based redactTokens stays for tests and future
composition.
Dead files
- resume-interrupted-tasks.ts: helper function never imported.
- service-graph-orchestration-deps.ts: three factory closures
superseded by the inline closures in create-orchestration-service.
Dead exports
- listConnectorsByAuthKind (shared/connector-catalog)
- getLocalePromptInstruction + whole localization.ts file
- ImportantDocType
- main-side ConnectorAuthKind (duplicated the shared one; not referenced)
- CheckpointEngineService, RunHousekeepingService types
- WorkspaceResolution type
- cmdOrCtrlSymbol
- AgentEventPush, CredentialPayload type aliases
- parseApprovalPayloadJson, parseEnvValue, ProviderCard
- getSOP — buildSOPSection reads the SOPS map directly; the individual
getter had no callers. Deduped to a flat array.
- renderWakeReason in wake-reasons/en.ts: byte-for-byte duplicate of the
one in wake-reasons/index.ts.
Forgotten wiring (not cleanup, but adjacent)
- registerAgentStandupRoute: the GET /api/agents/me/standup endpoint is
advertised in every system prompt and wake-reason doc, but the route
handler was never registered in api-server/index.ts. Agents have been
hitting 404 since Pi Mono. Registered.
Dead DB column
- v44 migration drops runs.context_snapshot_json. Column was added by
v16-v25, never written or read, and v43's deliberate preservation
was an interim measure. Same idempotent table-rebuild pattern as
v41/v43.
Test tree
- Phantom-feature threshold override tests removed (3). The rest of
the rotation suite re-expressed against the real maxRuns=20 floor.
495/495 unit tests pass. typecheck + build clean.
These columns and the messages that plumbed them existed for the CLI-
adapter era — Gemini's resume id and provider-specific session params
rode the run-finished envelope back onto the session row so the next
turn could resume. Pi Mono dispatches every provider through pi-ai and
emits neither value. The worker has been posting literal null on every
finish, finalizeRun has been writing null into task_sessions, and
rotateSession has been carrying null forward.
Remove the wire, the writer, the carry-forward, and the columns:
- v43 migration rebuilds task_sessions without session_params_json /
session_display_id, and runs without session_display_id. Follows
the idempotent "create new, copy, swap, reindex" pattern used by
v41. context_snapshot_json is preserved (also unreferenced today
but outside this cleanup's scope).
- RunJob / EnqueueRunPayload / ChildMessage / worker-types zod
schema no longer carry the fields. finishRun() / createNewSession()
signatures tighten in lockstep.
- rotateSession starts the new session with fresh counters and no
carry-forward.
- turn-context resolveSession returns just the session id.
- TaskSessionRecord / RunRecord lose the dead fields.
498/498 unit tests pass. typecheck + build clean.
The CLI-adapter era `connector.command` field became vestigial once every
provider dispatch went through pi-ai. The schema still required a non-empty
command, so onboarding saveConnector silently rejected every Pi Mono row
(which carries an empty default). Remove the field from the payload chain
(renderer → IPC schema → DB CRUD → run dispatch → worker job), narrow
RunJob.taskId to match EnqueueRunPayload, and drop the dead helper plus
its test. Schema column stays NOT NULL for DB compatibility; seedConnectors
writes a literal empty string so no migration is needed.
Symptom: scheduler logs `[heartbeat] Waking agent X (interval: 120s)`
every 15 seconds for three ICs, but no `[heartbeat] Starting taskless
heartbeat run` ever follows. Agents are wedged, UI shows nothing, and
the operator has no way to see why.
The loop had three compounding flaws:
1. `startAgentHeartbeatRun` returned `string | null`. Every gate
rejection was a silent `return null` — most were `logger.debug`,
which prod log levels filter out. Neither the scheduler nor the
renderer could tell the difference between "ran fine" and
"blocked by budget / no tasks / pending approval." The function
name promised "run or not" and the enum of reasons lived in
comments, not in the type.
2. The scheduler logged `[heartbeat] Waking agent ${name}` BEFORE
calling start, so even pure no-ops spammed the log. The "wake"
wasn't actually a wake; it was an attempt.
3. `markHeartbeatAt` is only called when a run starts. When start
returns null, `lastHeartbeatAt` stays frozen. On the next 15s
tick, `now - lastHeartbeatAt >= intervalMs` is still true, and
the scheduler fires again. The loop was a fixed-point at "skip →
log → skip → log" running 8 times per minute until something
external changed.
Root fix (data structure + visibility):
- `startAgentHeartbeatRun` now returns a discriminated union:
`{ status: "started"; runId } | { status: "skipped"; reason }`,
with `HeartbeatSkipReason` closed over 16 named reasons. Every
exit path carries its reason. The type tells operators what the
function can actually do; the enum tells them what it did.
- Scheduler holds a per-agent `attemptedAtByAgent` map. The interval
check uses `max(lastHeartbeatAt, lastAttemptedAt)`, so a streak of
skips still respects the heartbeat interval. Failed attempts cool
down the same as successful runs.
- On `skipped`, the scheduler persists `{reason, at}` into
`agents.runtime_state_json` under `heartbeatSkip`. On `started`,
it clears the field. The UI can read per-agent skip state from
the snapshot without a new IPC or table; operators finally see
"agent waiting: no_actionable_tasks" instead of a blank screen.
- The `Waking agent` log is gone. Its replacement prints once per
actual skip, with the reason inline (`[heartbeat] X not ready:
no_actionable_tasks`), and the existing `Starting taskless
heartbeat run for X` keeps marking the happy path.
`wakeAgentIfPossible` keeps its external `string | null` contract;
only the internal call site adapts. No caller outside of
`heartbeat.ts` / `scheduler-heartbeat.ts` / `scheduler.ts` needed
changes.
File sizes: heartbeat.ts 290, scheduler-heartbeat.ts 133. All under
the 400 cap. Tests: 603/603 green. Typecheck clean.
The per-connector "enabled/disabled model" settings lived under a
global key in the settings key-value table
(`disabled_models:${connectorId}`). No company dimension. Deleting a
company left the pool choices behind, and creating a new company
silently inherited them — the visible symptom was "onboarding's team-
model configuration survives company deletion and pollutes the next
company's CEO model list."
Root fix, not a cleanup hook: the storage becomes a real relational
table with `(company_id, connector_id)` primary key and an
`ON DELETE CASCADE` FK from `companies`. Company deletion wipes pool
config by construction; no cleanup step in `deleteCompany()` can
drift.
Schema (migration v42):
- CREATE TABLE connector_model_pool_settings (company_id, connector_id,
disabled_models_json, updated_at), PK (company_id, connector_id),
FK companies(id) ON DELETE CASCADE.
- Drop every `settings.disabled_models:*` key — these were the bug's
residue; there is no right company to attribute them to, so the
migration clears them. Users re-select on next onboarding.
API surgery — every reader/writer now takes companyId:
- db.getDisabledModels(companyId, connectorId)
- db.setDisabledModels(companyId, connectorId, models)
- db.hasModelPoolSettings(companyId, connectorId)
- connector-model-policy: getConnectorModelPolicy(deps, companyId,
connectorId); validateConnectorModelSelection(deps, companyId,
connectorId, model)
- model-dispatch: listEnabledModels(companyId, deps);
pickModelForAgent resolves companyId from agent.companyId
- orchestration heartbeat/task-runner/turn-context/run-dispatch pass
the agent's or run's companyId through to the policy deps
- IPC schema: connectorDisabledModelsSchema gains companyId; new
connectorModelPoolQuerySchema pairs companyId+connectorId for reads
Onboarding becomes commit-on-launch:
- useConnectorModelSelection no longer persists via IPC during
onboarding. Toggles mutate local state; it exposes `poolSelections`
to the wizard.
- bootstrapOnboardingSchema gains connectorPoolSelections. The
onboarding service saves the company first, then persists each
per-connector disabled list scoped to the new company id — CEO
model validation runs against the company's freshly-persisted pool,
not some stale global.
- IPC setConnectorDisabledModels now requires companyId; the only
consumer was onboarding, which no longer calls it at all.
- Main-side listConnectorModels returns the raw provider catalog
(no pool filtering), since that IPC is used from onboarding where
no company exists yet; pool filtering happens at dispatch time.
saveConnector IPC drops the per-company pool validation — connector
config is a global hint, company-scoped enforcement belongs at run
dispatch where the company is known.
Tests: 603/603 green. Typecheck clean. Integration tests rewritten
to set the pool via connectorPoolSelections on bootstrap input, not
a pre-bootstrap global setDisabledModels call.
Sessions have been agent-scoped since v34, but the schema kept four
dead task-scoped columns that every session code path had to pretend
were meaningful:
- tasks.active_session_id (mirrored agents.session_id)
- tasks.session_json (superseded by task_sessions.session_params_json)
- tasks.session_display_id (superseded by task_sessions.session_display_id)
- task_sessions.task_id (always empty-string on new rows)
v41 drops all four via SQLite table rebuild — the only safe path when
multiple columns need to go in one step and indexes / FK directions
need to be preserved. `PRAGMA foreign_keys = OFF` spans the swap
window so cascade-delete does not fire mid-rebuild. The migration is
idempotent and also backfills the optional task columns
(task_type / requires_user_review / retry_count / last_failure_at /
started_at / completed_at / cancelled_at / checkout_run_id /
execution_locked_at) before rebuilding so it works whether or not
`ensureOptionalColumns` has run yet on this install.
Along with the schema change, every code path that read or wrote
those columns has been rewired to the agent-scoped equivalents:
- task-sessions-crud.ts: deleted updateTaskSession,
getTaskSession, getActiveTaskSession, updateTasksActiveSessionForAgent;
added updateSessionParams(sessionId, ...) for the worker's
finalizer. ensureAgentSession and createNewSession no longer touch
task_id.
- database-base.ts: facade drops the now-unused pass-throughs.
- snapshot.ts / task-queries.ts / tasks-status.ts / entity-crud.ts:
SELECT projections no longer pull session_json / session_display_id
/ active_session_id from tasks.
- tasks-crud.ts (TaskRow) and shared/types/records-core.ts
(TaskRecord): sessionJson / sessionDisplayId fields removed.
- TaskSessionRecord: taskId field removed.
- turn-context.ts::resolveSession: no more
updateTasksActiveSessionForAgent mirror; sessionParams read directly
from the current session via getSessionById.
- run-finished-helpers.ts::finalizeRun: stamps sessionParams via
updateSessionParams on the agent's current session, resolved through
agents.session_id.
- session-management-service.ts: rotateSession stops calling
updateTasksActiveSessionForAgent.
Test suite updated to use the new session-id API. Existing tests used
`getActiveTaskSession(taskId)` and `createNewSession({ taskId, ...})`
as probes; they now use `getSessionById(sessionId)` and the taskId-less
createNewSession input.
Tests: 603/603 green. Typecheck clean. Migration tested on fresh DB
(runs v1→v41 cleanly) and idempotent re-apply (detects absent dead
columns, skips).
Sibling refactor to the sidebar-badges cleanup. The inbox aggregates
five item categories (pending approvals, failed runs, budget warnings,
blocked/review tasks, unread company/incident/urgent messages) — every
row is already in `ProfileSnapshot`. The server-side `getInbox`
aggregation (180 lines of SQL + projection) was a redundant second
path.
New: `src/shared/inbox-items.ts::deriveInboxItems(snapshot, companyId)`
produces the same InboxItem[] shape with the same severity ordering
and 24h failed-run window. Pure function, no IPC, no state.
Deleted:
- database/company-crud.ts::getInbox (180-line aggregation)
- database-base.ts::getInbox pass-through
- ipc-core-read-handlers.ts: handler
- preload/index.ts: bridge binding
- shared/contracts/channels.ts: channel name
- shared/contracts/desktop-api/companies.ts: API signature
- state/slices/company.ts: `inboxItems` field, `setInboxItems` action
- useDataLoader.ts: both getInbox call sites (initial snapshot load
and section-change effect)
- useCompanyController.ts: inbox reset on company switch
OperationsSections now computes `inboxItems` via `useMemo` off the
current snapshot + company id. The snapshot is already company-scoped
at the main/database/snapshot.ts layer when `currentCompanyId` is
non-null; the derivation re-filters defensively so the function works
correctly even for unscoped snapshots.
Tests: 603/603 green. No new tests added — the derivation is a pure
function whose shape is exercised end-to-end by the existing Inbox
component integration.
Badges were fetched via a dedicated IPC that aggregated counts on the
server, while the same data lived in ProfileSnapshot on the client.
Two paths to the same answer drift whenever one side mutates between
refreshes — the visible symptom being "badge = 1 unread" with an empty
inbox after a message gets marked read in another screen.
Delete the whole server path. Sidebar badges are now derived on the
client from the snapshot via `deriveSidebarBadges(snapshot, companyId)`
in `src/shared/sidebar-badges.ts`. One source of truth; drift is
physically impossible.
Deletions:
- database/company-settings.ts::getSidebarBadges (120-line SQL aggregation)
- database-base.ts::getSidebarBadges pass-through
- ipc-core-read-handlers.ts: handler
- preload/index.ts: bridge binding
- shared/contracts/channels.ts: channel name
- shared/contracts/desktop-api/companies.ts: API signature
- shared/contracts/schemas-badges.ts::sidebarBadgesSchema Zod schema
- renderer/state/slices/company.ts: `badges` field, `setBadges`,
`refreshBadges`
- useDataLoader: both getSidebarBadges call sites
- useCompanyController: badge reset on company switch
- useDesktopEvents: refreshBadges call on granular patches
- tests/unit/store.test.ts: refreshBadges test (deleted code)
The snapshot's agentMessages already carries board-reader read state
via boardReadReceiptMap (`snapshot.ts:39-47` has always LEFT JOIN'd
agent_message_reads with agent_id = BOARD_READER_ID). So derivation
needs no schema change — the infrastructure was in place, just unused.
fix(communication/MessageList): render orphaned-reply messages at the
top level
The thread-flattening used `filteredMessages.filter(m => !m.parentMessageId)`
to pick top-level messages. When an agent replies to a company/incident
post with a DM (the CEO prompt explicitly instructs this in
wake-reasons/en.ts:119), the DM lands in the direct channel with
parentMessageId pointing to a message that lives in another channel.
That parent is not in `filteredMessages` for the direct channel, so the
reply had no parent to nest under AND was excluded from top-level. The
channel rendered as a blank pane even though exactly one message
matched — the "clicked Dr. X ↔ Dr. Y, got empty" symptom.
Fix: a message is top-level when it either has no parent OR its parent
is outside the current filtered view. Replies with in-view parents
still nest normally.
Onboarding was a 7-step linear wizard, same information hierarchy as
Settings but behaving like a modal. Replace with the SettingsShell tab
pattern: one row of six tabs (nodecheck / providers / company /
modelpool / executionmode / mission), free navigation, per-tab
completion ticks derived from the wizard's own validation rules.
Sub-fixes on the same pass:
- ActionButton: add whitespace-nowrap + shrink-0 so "选择" never
stacks vertically on narrow containers. Global — every caller gets
the correct behavior
- CompanyStep: CEO Runtime Provider/Model go vertical (space-y-3
instead of grid-cols-2) so long names like "Fireworks AI" /
"Kimi K2.5 Turbo Preview 0911" stay readable; workspace-path Input
wrapped in min-w-0 flex-1 container so it yields, not the button
- MarkdownBody: render react-markdown in chat bubbles (was plain text)
i18n: added onboarding.stepsAria for the tablist label.
Sessions are already agent-scoped post-v34, but the code kept routing
rotation / accumulation / handoff through the task indirection. Heartbeat
runs (no task) silently skipped all three, letting taskless transcripts
grow past the model's context window — the 7M-token explosion.
- DB adds getSessionById(sessionId) — direct lookup bypassing
tasks.active_session_id
- session-management-service: generateHandoff(session) is now agent-scoped,
walking recent runs across tasks + heartbeats; getHandoffForAgent(agentId)
is the canonical lookup, getHandoffContext(taskId) is a thin shim for
callers that still hold a task id
- turn-context.ts::resolveSession drops the `if (!task)` early return:
rotation evaluation runs on the agent session unconditionally
- run-finished-helpers.ts::accumulateAndRotateSession drops the `!task`
gate: tokens accumulate and rotation fires for every run, keyed on the
agent session, not the task
- Rotation evaluator contract: evaluatePreRunSessionRotation(agentId,
connectorId) → { rotated, handoff, sessionId }; old (taskId, connectorId)
signature removed
IPC contract tightening: ipc-support::emitEvent becomes a best-effort
sink. "Render frame was disposed" during renderer reload is a lifecycle
signal, not a logic error; silently drop it instead of letting it abort
downstream event emissions.
Artifact projection: pi's write/edit tool results now upsert a
`work_products` row via a new agent-runtime/artifact-projector.ts. No
more agents writing 54 files into the workspace that the UI never sees.
Migration v40 relaxes work_products.task_id to nullable so heartbeat
artifacts can register too.
File size: split agent-event-projection.ts out of runtime-bridge.ts to
keep the bridge under the 400-line cap after adding the projector hook.
Tests: 604 unit + integration green; generateHandoff callsites updated
for the single-argument signature; artifact-projector has pure-function
coverage via the projector module's own shape.
Test 7 asserted navigation.section === "orgchart", but commit 36bed1b
renamed the section to "agents" when deleting the orgchart view. The
production code emits "agents"; only the test was stale.
Drop code that nothing consumed. Verified by full renderer+shared grep:
every removed channel had zero callers via window.agentCompany.* and
zero references from desktop-api types. No backend internal use either —
the database/API-server layers keep their methods, only the IPC exposure
goes.
Removed IPC handlers (26 total):
Thread CRUD surface (12) — writes happen inside the backend
createThread, listThreads, addThreadEvent,
setThreadWaitState, resolveThreadWaitState,
addParticipant, removeParticipant, listParticipants,
subscribeThread, unsubscribeThread,
listSubscriptions, listSubscribedThreads
(kept reads: getThread, listThreadEvents, listThreadWaitStates)
Half-implemented features (13)
saveSecret, listComments, getAgentMetrics, getApiPort,
listWakeupRequests, listAgentMessages, autoAssignTask,
listRunEvents, recoveryDismiss,
getHeartbeatPolicy, setHeartbeatPolicy,
getRuntimeState, setRuntimeState
Whole vertical slice: openConnectorAuthTerminal
IPC handler + channel + preload + DesktopApi type +
connector-auth-service function + useConnectorCallbacks
handleLoginConnector + OnboardingContext.onLoginConnector +
OnboardingWizard wiring. Pi-mono has no CLI auth flow;
the no-op that "returned false for legacy UI compatibility"
had no legacy UI left.
Orphaned modules (zero importers in repo):
src/main/runtime-env.ts
src/main/database-stale-recovery.ts
Dead schema files (only referenced by their own barrel re-export):
schemas-participants, schemas-subscriptions, schemas-heartbeat,
schemas-run-events, schemas-runtime-state, schemas-wakeup
Renderer leftovers:
.activity-row-enter CSS + its two unreferenced keyframes
5 orphan i18n keys (app.console, app.refresh, app.expand,
activity.timeline, auto.trigger) left over from earlier feature
deletions
Also update two unit tests that mocked openConnectorAuthTerminal in
the RegisterCoreHandlersDependencies pick.
Remove files that should never have been tracked:
- pi-mono-reference/: 688-file copy of an upstream monorepo, zero imports,
used only as offline human reference
- build/electron-builder.runtime.json: environment-specific build artifact
with hardcoded local paths
- state/cancel-signal-state.json: runtime state written by the app
- prompt.txt, logo.svg (root): exact duplicates of the canonical copies
under .planning/archive/ and assets/
Add state/ to .gitignore so future runtime state stays out.
Removing what a reader doesn't need:
- Subtitle shrunk from three sentences to one. "Run an AI company from
your desktop." The two follow-up sentences ("Give it a mission.
Watch agents hire, delegate, and deliver.") were restating the same
thing twice.
- "Built with quiet authority. Ship your AI company." closing tagline
deleted. AI-flavored vapor.
- Feature openers trimmed. Each feature is now [bold title] + [one
sentence]. The previous paragraphs qualified, reassured, and
explained their own engineering trade-offs ("context travels with
the work instead of being re-rendered from scratch every run"). A
reader skimming the README doesn't need the trade-off; the architecture
section has it.
- "No plaintext, no ~/.pi/agent/auth.json, no scattered env files"
rhetorical triad replaced with "Credentials are SecretVault-encrypted
in SQLite." One fact, stated once.
- "Agents are at work before your coffee" deleted. Cute-cringe.
- "the company respects the chain of command" deleted. Hollow.
- "What is AgentCompany?" section compressed from three paragraphs to
two sentences.
- Image alt text shortened where it was describing the screenshot to
the screenshot ("AgentCompany Dashboard — active agents, tasks, and
company metrics" → "AgentCompany dashboard"). Alt text is for screen
readers landing on the page, not for search engine padding.
- Top-nav link list trimmed from 4 entries to 2 (Download, Website).
The in-page anchors were noise.
Facts preserved verbatim: 26 providers with correct per-group counts
(5/18/3), the two execution modes with their approval gate names,
architecture tree, tech stack, first-run steps, build commands.
Nothing factual removed.
Line count: 186 → ~170, but the bigger win is prose density — every
paragraph now says exactly one thing.
The previous README described an app that no longer exists:
- Listed "Claude Code / Codex CLI / Gemini CLI" as the supported runtimes
— all three were removed from the app months ago; pi-mono replaced
them as the sole agent runtime (commit 4de9cd0 + 60178ce).
- Advertised `packages/adapter-utils` + three adapter packages under
`packages/` — that directory was deleted.
- Carried a "PLEASE USE A CODING PLAN" warning that predated OAuth
subscription support; five subscription providers (Claude Pro, ChatGPT
Codex, Copilot, Gemini CLI, Antigravity) are now first-class.
- Made no mention of the 26-provider catalog, the AI CEO vs
AI Co-Founder execution modes, the encrypted credential vault, the
thread runtime, or any of the architecture that has become the app.
- Promised "I will add support for Ollama and Github Copilot" — Copilot
has shipped for months.
New README covers what the app actually is today:
- Pi-mono as the one runtime, linked as a direct npm dependency.
- 26 providers in total: 5 OAuth subscriptions + 18 API-key providers
+ 3 cloud providers — counts verified against the on-disk catalog
files (connector-definitions-data-{oauth,apikey,cloud}.ts).
- Two execution modes (AI CEO, AI Co-Founder), per-company, with the
specific approval types listed for Co-Founder mode's review gates.
- Encrypted credential storage (SecretVault-backed SQLite) instead of
plaintext env files or pi's default ~/.pi/agent/auth.json.
- Real agent-to-agent messaging layered on pi's AgentMessage stream
via the thread runtime.
- Architecture tree updated: `packages/` gone, main-process subtrees
(agent-runtime / credentials / api-server / orchestration / worker /
thread-integration / database) named explicitly.
- First-run flow that accurately describes what onboarding does —
including the real Node-in-PATH probe (commit ab731e6) agents need
for HTTP callbacks.
- Tech stack row for pi-mono packages added.
Kept intact: screenshots, download/build/verify/packaging commands,
licence. Badges updated to match the cross-platform targets currently
shipping.
Deleted: the "Supported AI Runtimes" table (obsolete), the coding-plan
token warning (obsolete), the future-plans line (Copilot already
shipped, Ollama not on the roadmap).
Language pass: Jobs-style tightening of the "What it does" paragraph
and section openers; subtitle changed from "Launch autonomous AI
companies..." to "Run an AI company from your desktop." — shorter,
more direct, stops selling and starts describing.
"删除 OrbitOps AI?" was being truncated to "删除O..." inside the
~100px inline confirm row, because the row has to share width with
the 是 / 否 buttons. The name-interpolation was the wrong half of
the message to keep: the user has just clicked the trash icon on a
specific row, so **which** company is about to be deleted is
already anchored by the row they are looking at. The bare
confirmation "删除?" / "Delete?" fits comfortably and loses zero
information.
- zh key: "删除{name}?" → "删除?"
- en key: "Delete {name}?" → "Delete?"
- Removed the `.replace("{name}", company.name)` call and the
fallback string that also interpolated `company.name`.
No visual change to the 是 / 否 buttons; the confirm still blocks
the row, still requires an explicit click. The only thing gone is
the truncated company-name echo.
Three subtractions on the 220px-wide sidebar top bar, where the
company name was being truncated to "OrbitOps ..." inside the button
AND to "Orbit..." inside the open dropdown. Fixing the truncation by
widening the sidebar would be adding space; Jony+Jobs read this as a
signal that too many things were competing for the same width.
Removed:
1. `CaretUpDown` icon at the right edge of the switcher button
(14px + 8px gap = ~22px). A clickable company name is obviously a
picker — the same pattern the Mac menu bar and Slack workspace
switcher use without a perpetual caret attached. The button's
hover state + the dropdown itself carry the affordance.
2. `<kbd>⌘K</kbd>` label inside the command-palette button beside
the switcher (~30px). The palette already opens from the global
⌘K shortcut, which persists unchanged; the button's `title`
attribute keeps the shortcut discoverable on hover. Collapsed-
sidebar mode already rendered the palette button icon-only —
expanded mode now matches that baseline instead of duplicating
the accelerator hint.
3. The 16px `w-4` check column inside each dropdown row. The active
company is already marked by `font-semibold text-foreground`
against the other rows' `text-muted-foreground` — double-marking
"this is current" with a ✓ AND bold + colour was the same
information twice, and it stole width from names that were
themselves being truncated.
Net effect: switcher button gains ~52px of text width; dropdown rows
gain ~22px. Companies with names like "OrbitOps Solutions" that
previously truncated now render in full at the default sidebar width.
Typecheck + lint pass. Unused `CaretUpDown` / `Check` icon imports
dropped.
Two subtractions in the message bubble header row:
1. Department chip next to each agent name. Department was already
communicated by (a) the avatar tile colour (`bg-{color}18`, tinted
by department via DEPARTMENT_COLORS) and (b) the sender name's
text colour (`color: senderColor = deptColor`). The explicit
uppercase chip ("ENGINEERING", "PRODUCT", …) was the third signal
in the same row and the loudest — hardest-to-read typography
(10px uppercase tracking) re-stating what colour already says.
Deleted; colour continues to carry the semantics.
2. `CheckCircle` as the read-state marker. It only appeared
on hover at 60% opacity — invisible most of the time and purely
decorative when visible. The absence of the unread dot already
communicates "read"; a second glyph just to mark the negative
case added nothing. Deleted along with the now-unused CheckCircle
import.
Typecheck + lint pass.
HiringPipeline carried three panels that restated information already
shown — one with more emphasis — on the Team page or in the stage
filter directly above them:
1. "Pending approval" mini-Panel (lines 121-187): listed the same
candidates that the user reaches by clicking the `pending_approval`
chip in the stage filter at the top of the page. The mini-Panel
also carried its own Approve / Reject buttons, but those approvals
are resolvable from Approvals section too. Doubling the approval
path increases the chance of racing clicks between the two
surfaces. Deleted.
2. 4-card summary row at the bottom (lines 246-267): Total agents /
Pending hires / Departments / Monthly payroll. The first two are
rendered on the Team page by TeamHeaderSummary (commit 36bed1b).
Payroll can return via a Team-page dedicated surface when there
is a business reason; until then it is clutter. Deleted.
3. "Department headcount" Panel (lines 269-293): grid of active+
pending count per department. OrgChart already shows this through
the department colour on each agent card, and TeamHeaderSummary
covers the aggregate. Two places describing the same fact is one
too many. Deleted.
Also:
- `onRefresh` prop removed from HiringPipeline + OrganizationSections
caller — only the (now-deleted) approve/reject buttons used it.
- `Card`, `UserPlus`, `Check`, `unwrap` imports dropped because the
deletions made them unused.
- Unused `i` index in Dashboard.tsx activeWork map callback cleaned
up (left over from the previous commit that removed the
arbitrary animation gate).
File size: HiringPipeline.tsx 296 LOC → 175 LOC (net −121).
Typecheck + lint pass.
Each inbox row previously carried a `text-[9px] uppercase tracking-
[0.1em]` pill next to the title that re-stated the item's type
(APPROVAL / REVIEW / FAILED RUN / …). The row already communicates
type through three stronger channels:
1. The coloured icon tile on the left (`config.icon` + `config.bg`)
2. The filter chip above the list when a type filter is active
3. The border accent colour tied to severity
The 9px pill added another restatement in the smallest, hardest-to-
read typographic register on the page. Deleted. The title row now
just shows the item title; the icon carries the category semantics.
No copy or behaviour change beyond the visual subtraction.
Two subtractions surfaced by the fresh Jony+Jobs pass on the Dashboard:
- The "Company status line" at the top of the dashboard showed three
counters: `{activeAgents} agents active · {activeTasks} tasks in
progress · {completed} completed`. The 4-card MetricsGrid directly
below it already rendered the same agent count and active-tasks
count as its primary tiles. Seeing the same number in vertical
succession is the cheapest way to tell users "we weren't sure
what to show." Deleted the whole status-line block + the companyName
prop + the now-unused caller wiring in OperationsSections.
- The Active Work list rendered a `animate-ping` halo on the first
two running runs (`i < 2`) and skipped it for the rest. Arbitrary —
either the ping is a meaningful "I'm active" signal for every
running run, or it is decoration. Dropped the ping entirely; the
solid green dot on the robot icon is already enough to say
"running," and losing the double-layered animation lets the
dashboard feel calm instead of twitchy.
Dead i18n keys removed alongside (zh + en):
dash.agentActive / dash.agentsActive / dash.taskInProgress /
dash.tasksInProgress / dash.completedCount.
Typecheck clean.
The Edit modal was still a vertical scroll of 5 stacked sections (12+
form fields + an embedded Heartbeat card + action bar) even after C5
named the sections. A modal is a transient affordance — making the
user scroll inside it was the wrong design. Same IA move we made for
the Settings page one round ago: add tabs.
Tabs inside the modal:
Identity | Runtime | Budget & scope | Behavior
Each tab swaps the pane. Tab label carries the section context, so
every section body lost its SectionHeader wrapper — the nested
"card-in-a-card" look around HeartbeatSection is gone; Behavior tab
just renders the heartbeat controls directly.
Field-level polish bundled in the same pass because it hit the same
render paths:
- Identity tab: Name + Role paired in a 2-column grid; Department +
Reports To paired. Title spans full width because its values are
often longer. The modal no longer grows tall just to fit two short
inputs side by side.
- Runtime tab: Status stands alone at the top (high-frequency
control — pause/resume). Connector + Model paired; Thinking Effort
+ Workspace paired. Each paired row keeps its inline hint (connector
issue / effort-not-supported) tied to the field it belongs to,
instead of appearing as loose paragraphs between fields.
- Budget tab: Budget input + BudgetBar + burn line cluster into one
grouped column so typing a new cap updates the visual context in
place.
- Behavior tab: HeartbeatSection rendered at the tab root — no
SectionHeader wrapper around it. The card's "Continuous Execution"
title + Wake-now button now read as the primary content of the
tab, not as something buried beneath a header.
Also removed:
- `<input type="hidden" data-agent-id={agent.id} />` in RuntimeSection
— dead code, zero semantic purpose, leftover from an earlier
experiment. Caught during the audit.
- `SectionHeader.tsx` — no more callers after sections stopped
wrapping themselves in it. File deleted.
Added i18n keys (zh + en):
agent.form.tabsAria.
Tab-label copy reuses the existing `agent.form.sectionIdentity`,
`sectionRuntime`, `sectionConstraints`, `sectionBehavior` keys —
same strings that the removed SectionHeader rendered, now carried
by the tab labels.
Covers EJ1 / EJ2 / EJ3 / EJ4 / EJ5 / ES1 / ES3 / ES4 from the
second-round Edit-modal audit.
Profile path and secret backend are **read-only diagnostic facts** —
they tell you which directory the profile lives in and which keystore
ciphertexts are stored against. They are not preferences; they do not
belong in the General tab alongside the theme / language / auto-update
controls.
Moved both into a new "Profile" group inside the Data tab, rendered
via a small `ReadOnlyRow` helper so both pieces of information share
consistent bordered-card styling and get a clear label/value split.
General tab now contains only the three actual preferences (theme,
language, updater) + the inline updater hint.
The previous General rendering dropped profile path as raw mono text
under the updater checkbox — visually tangled with the preference
control. With diagnostics in Data and preferences in General, each
tab reads with a single purpose.
No new i18n keys required — reused `exec.profile` / `exec.profilePath`
/ `exec.secretBackend`, which were already translated in zh + en.
Follow-up polish; no behavioral change. Typecheck + lint pass.
The settings page was a single vertical scroll containing seven
unrelated concerns. Users had to scroll past theme preferences to
reach Providers — which is the most-touched surface — and mode +
secrets + shortcuts lived below that. There was no signal about
where anything was.
Introduced `SettingsShell` with a horizontal tab row along the top:
General | Execution mode | Providers | Shortcuts | Data | Secrets
Each tab swaps the content pane. Active tab is visually anchored
with a 2px primary underline + foreground text; inactive tabs stay
muted with a hover promotion. All tabs are real `role="tab"` +
`aria-selected` so screen readers announce the current pane; the
nav has `aria-label="Settings categories"`.
Tab state is local — settings are ephemeral navigation, no reason
to persist across company switches or app restarts. Default is
"general" because that's where first-time users land.
Refactor consequences:
- `OperationsSections.tsx` now renders `<SettingsShell />` + the
OAuth prompt modal portal; the old double-Panel wrapper with
"Profile and release" + "Providers" titles is gone. The "Profile
and release" label always fought with what was actually inside
the pane — Jobs audit item S-S2 addressed.
- Providers moved from a separate sibling Panel into the main IA.
Previously Providers came AFTER a 7-section scroll; now it is a
peer of General / Mode / etc., reachable in one click.
- Deleted the now-unused `SettingsPane.tsx` orchestrator. Its
single-column rendering survived only as a composition step that
SettingsShell replaces.
Added i18n keys (zh + en):
settings.tabs.general / mode / providers / shortcuts / data /
secrets; settings.tabsAria.
Covers J-S2 / J-S7 / S-S5 / S-S7 from the settings audit.
The previous SettingsPane.tsx hid 8 unrelated concerns behind 73 "lines"
of JSX where each line ran 400-2000 chars. Reading the file was
infeasible; editing it meant scrolling horizontally through a single
statement spanning every button, modal, and table row.
Split by concern into `settings-sections/`:
SettingsPane.tsx 62 LOC orchestrator
GeneralSection.tsx 94 LOC theme / language / auto-update
ExecutionModeSection.tsx 105 LOC AI CEO ↔ AI Co-Founder toggle
ShortcutsSection.tsx 40 LOC keyboard reference (read-only)
DataSection.tsx 123 LOC update check / backup / restore /
secret backend
SecretsSection.tsx 102 LOC per-company secret bindings
Every file now reads top-to-bottom with normal indentation.
Behavioral changes bundled (they belong with the restructure, not a
separate pass):
- Preferences save model unified: theme already auto-saved on change;
language and auto-update checkbox now do the same. The global
"Save settings" button is gone — there is no longer a state where
a toggled language sits unpersisted waiting for a second click.
- Execution mode card gained a subheader line that names the current
company ("Applies to current company: X"). Multi-company users can
no longer confuse which company they just flipped.
- Auto-updater checkbox gained an inline hint: "Check for application
updates at launch" — the previous naked checkbox never explained
what happens when it is off.
- Restore profile moved into DataSection's backup/restore group,
given `tone="danger"`, and gated behind a `window.confirm` with
copy warning about running work being lost. Backup stays neutral.
The previous layout put Restore in the same 2-col grid as Save +
Check Updates + Backup, making a profile-replacing operation look
as routine as saving a theme preference.
- Secret backend display promoted from a naked text line to a
bordered card so it matches the surrounding section rhythm.
Added i18n keys (zh + en):
settings.updaterHint / settings.modeScope / settings.backupRestore /
settings.restoreConfirm / settings.restoreHint.
Covers J-S1 / J-S6 / S-S1 / S-S2 / S-S3 / S-S4 from the settings audit.
Remaining work (tab sidebar, Providers IA reordering, copy sweep)
lands in follow-up commits.