Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7e85185
fix(vault): pad the API Key Vault modal body and lay out its header
Juliusolsson05 Sep 8, 2026
e3c111b
fix(sessions): say when a session's workspace folder is gone
Juliusolsson05 Sep 8, 2026
1fda16b
fix(settings): treat absent localStorage as unavailable, not as a liv…
Juliusolsson05 Sep 8, 2026
e4ffcaa
fix(workspace): reserve the agent-name row so panes stop resizing aft…
Juliusolsson05 Sep 8, 2026
9468cd8
fix(settings): read the localStorage global, not window.localStorage
Juliusolsson05 Sep 8, 2026
919ef90
fix(sessions): stop a no-op wake from cancelling the caller that requ…
Juliusolsson05 Sep 8, 2026
b8fe179
fix(terminal): render pane toasts on plain terminal panes
Juliusolsson05 Sep 8, 2026
9b76d5d
fix(provider-switch): keep the agents a bulk return could not bring home
Juliusolsson05 Sep 8, 2026
3c54719
docs(audit): record the 2026-09-08 post-merge regression audit
Juliusolsson05 Sep 8, 2026
fcf70d0
fix(terminal): stop attaching the WebGL renderer until a stable addon…
Juliusolsson05 Sep 8, 2026
d0b0be9
fix(provider-switch): report transient refusals honestly and stop los…
Juliusolsson05 Sep 8, 2026
49eb251
fix(provider-switch): close the bulk modal's double-run and stale-con…
Juliusolsson05 Sep 8, 2026
1192115
fix(workspace): stop burning an agent name on every replacement
Juliusolsson05 Sep 8, 2026
04c1fbf
fix(workspace): make Jump to Latest work on OpenCode Terminal panes
Juliusolsson05 Sep 8, 2026
8a8ff29
fix(prompt-templates): abort loudly on a bad key reference, as docume…
Juliusolsson05 Sep 8, 2026
515c5da
fix(vault): return terminal refusals as results and stop leaking a ke…
Juliusolsson05 Sep 8, 2026
1055280
fix(prompt-templates): stop the key grammar from capturing ordinary text
Juliusolsson05 Sep 8, 2026
c7269fc
fix(vault): keep the provider list reachable in a narrow window
Juliusolsson05 Sep 8, 2026
9c4cf5a
fix(provider-switch): key the size estimate on what actually decides it
Juliusolsson05 Sep 8, 2026
7c91741
fix(workspace): truncate pane paths from the start, not the end
Juliusolsson05 Sep 8, 2026
e560e88
fix(workspace): reserve the identity carry for every replacement
Juliusolsson05 Sep 8, 2026
7618be2
fix(terminal): restore the TUI's screen and mouse modes when a pane a…
Juliusolsson05 Sep 8, 2026
8f3c02b
docs(audit): record the scroll root cause and the merge review
Juliusolsson05 Sep 8, 2026
1ac7a1a
fix(terminal): carry a partial mode sequence across PTY chunk boundaries
Juliusolsson05 Sep 8, 2026
737be88
fix(terminal): carry a partial escape prefix, not just a partial para…
Juliusolsson05 Sep 8, 2026
3141397
revert(terminal): drop the mode tracker; its design is wrong, not jus…
Juliusolsson05 Sep 8, 2026
8aa2860
revert(prompt-templates): narrow the key grammar back; a slash is not…
Juliusolsson05 Sep 8, 2026
4e36734
revert(workspace): drop the OpenCode jump chord injection
Juliusolsson05 Sep 8, 2026
4b88c71
fix(provider-switch): align the last two guards and delete a stale co…
Juliusolsson05 Sep 8, 2026
59e7f7d
fix(workspace): use the browser-verified form of start truncation
Juliusolsson05 Sep 8, 2026
045e46c
docs: drop two stale references left by the reverts
Juliusolsson05 Sep 8, 2026
41e474d
Merge remote-tracking branch 'origin/main' into fix/post-merge-regres…
Juliusolsson05 Sep 8, 2026
254046c
fix(tests): stub the directory guard in the other suite that builds a…
Juliusolsson05 Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
686 changes: 686 additions & 0 deletions docs/superpowers/research/2026-09-08-post-merge-regression-audit.md

Large diffs are not rendered by default.

13 changes: 10 additions & 3 deletions src/main/agentNames/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { ipcMain } from 'electron'
import { join } from 'node:path'
import { z } from 'zod'

import {
AGENT_NAME_IDENTITY_MAX_LENGTH,
AGENT_NAME_IDENTITY_REQUEST_MAX,
} from '@shared/types/agentNames.js'
import { AgentNameRegistry } from '@main/agentNames/registry.js'
import { STATE_DIR } from '@main/storage/paths.js'
import { getBrowserWindow, windowIdFor } from '@main/window/windowRegistry.js'
Expand All @@ -14,9 +18,12 @@ import { getBrowserWindow, windowIdFor } from '@main/window/windowRegistry.js'
// address — so the two must not be able to take each other down.
export const AGENT_NAMES_FILE = join(STATE_DIR, 'agent-names.json')

// Bounded so a malformed or hostile renderer cannot make the allocator walk a
// huge list under the serialization tail. 10k is far past any real workspace.
const requestSchema = z.array(z.string().min(1).max(200)).max(10_000)
// Both bounds come from the shared contract, because the renderer's
// `identityOf` has to refuse exactly what this refuses — see
// shared/types/agentNames.ts for the batch-wide failure that drift caused.
const requestSchema = z
.array(z.string().min(1).max(AGENT_NAME_IDENTITY_MAX_LENGTH))
.max(AGENT_NAME_IDENTITY_REQUEST_MAX)

/**
* The ONLY consumer of AgentNameRegistry.
Expand Down
59 changes: 55 additions & 4 deletions src/main/agentNames/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,30 @@ function adoptAssignments(source: Record<string, string>): Record<string, string
export class AgentNameRegistry {
private state: RegistryState | undefined

/**
* Normalized spoken names currently assigned, kept alongside `state`.
*
* WHY it is cached instead of rebuilt: `allocate` used to walk every value
* and normalize it on EVERY call, purely to answer "has a hand-edited file
* already used this name". That is O(assignments) work per allocation for a
* question whose answer only changes when this process assigns something,
* and this process is the single writer after the first load. `load` already
* builds exactly this set for its duplicate check, so caching it there costs
* nothing and makes the common path proportional to the NEW names.
*
* WHY there is still no prune path, which would bound the file properly:
* assignments can only be dropped by knowing which identities are still
* live, and no single window knows that. A window holds its own workspace,
* not the others', so pruning against one window's identity set would delete
* names belonging to agents open in another window and hand those names out
* again. That is a correctness bug traded for a size nicety. The growth rate
* was the real problem and it is fixed at the source: successors mid-replace
* no longer burn an allocation each (see the renderer's
* pendingIdentityCarry), so the file now grows once per genuinely new agent
* rather than once per reload, resume, rewind and provider switch.
*/
private usedNames: Set<string> | undefined

// WHY one promise tail rather than a mutex or a per-identity lock: every
// allocation reads the whole counter and writes the whole file, so the
// critical section is the entire operation. Two windows starting agents in
Expand Down Expand Up @@ -129,6 +153,7 @@ export class AgentNameRegistry {
throw new Error(`Agent name registry at ${this.path} is unreadable; refusing to overwrite it`, { cause: error })
}
this.state = { version: 1, nextIndex: 0, assignments: emptyAssignments() }
this.usedNames = new Set()
return this.state
}

Expand Down Expand Up @@ -167,7 +192,13 @@ export class AgentNameRegistry {
// name makes every later lookup ambiguous with no evidence for choosing
// between them.
const spoken = Object.values(assignments).map(normalizeAgentName)
if (new Set(spoken).size !== spoken.length) throw new Error('Two identities share one spoken name')
const distinct = new Set(spoken)
if (distinct.size !== spoken.length) throw new Error('Two identities share one spoken name')
// Assigned here rather than after the try, so a file that fails
// validation leaves BOTH `state` and this cache unset — the refusal has
// to be all-or-nothing or a later allocation would consult a set that
// describes a registry we refused to load.
this.usedNames = distinct
} catch (error) {
// WHY this is not cached and not repaired: leaving `this.state` unset
// means every later call re-reads and re-fails, so the user gets a
Expand All @@ -188,7 +219,14 @@ export class AgentNameRegistry {
// we mutated in place, a write error would leave this process believing it
// had published names that are not on disk.
const draft: RegistryState = { ...loaded, assignments: adoptAssignments(loaded.assignments) }
const used = new Set(Object.values(draft.assignments).map(normalizeAgentName))
// `load` guarantees this alongside `state`; the fallback keeps the type
// honest without pretending an unloaded registry is an empty one.
const used = this.usedNames ?? new Set<string>()
// Every name this call adds, so a failed commit can undo its effect on the
// shared cache. The draft's assignments are already a copy and roll back
// for free; this set is not, because copying it per allocation is the cost
// being removed.
const added: string[] = []
let changed = false

for (const identity of identities) {
Expand All @@ -210,11 +248,24 @@ export class AgentNameRegistry {
// would have to reconcile two divergent counters with no evidence.
while (used.has(normalizeAgentName(name))) name = agentNameAt(draft.nextIndex++)
draft.assignments[identity] = name
used.add(normalizeAgentName(name))
const normalized = normalizeAgentName(name)
used.add(normalized)
added.push(normalized)
changed = true
}

if (changed) await this.commit(draft)
if (changed) {
try {
await this.commit(draft)
} catch (error) {
// Same contract as the draft copy above: a write that did not land
// must leave this process believing nothing was published. Leaving the
// names in the cache would make the retry skip past them and burn the
// vocabulary for allocations that never happened.
for (const name of added) used.delete(name)
throw error
}
}
return Object.fromEntries(identities.map(identity => [identity, draft.assignments[identity]]))
}

Expand Down
17 changes: 16 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,22 @@ const caffeinateController = new CaffeinateController()
// no async boot step — only the per-run unlock boolean.
const vaultService = new VaultService({
store: createFileVaultStore(join(STATE_DIR, 'key-vault'), createSafeStorageCodec()),
promptAuth: reason => systemPreferences.promptTouchID(reason),
promptAuth: async reason => {
// WHY the platform check lives HERE and not in ensureUnlocked: the service
// deliberately attempts the prompt rather than pre-gating on
// canPromptAuth, because that flag once reported biometric capability only
// and pre-gating locked out every password-only Mac from the login-password
// path this feature promises. That reasoning is right for capability. It is
// NOT right for a platform that has no promptTouchID at all: there,
// "attempt it" meant calling undefined, and the user got a raw
// "systemPreferences.promptTouchID is not a function" TypeError instead of
// the honest platform message. Fails closed either way; only the wording
// was broken.
if (process.platform !== 'darwin' || typeof systemPreferences.promptTouchID !== 'function') {
throw new Error('The API key vault needs macOS Touch ID or login-password authentication, which this platform does not provide.')
}
await systemPreferences.promptTouchID(reason)
},
// canPromptTouchID checks biometrics, not user-presence/password auth.
// Electron 43's promptTouchID uses SecAccessControlUserPresence; attempt
// that supported macOS API and let rejection keep the vault locked.
Expand Down
15 changes: 15 additions & 0 deletions src/main/sessionManager.codexReplacement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ const { createSession, resolveTranscriptPath } = vi.hoisted(() => ({
resolveTranscriptPath: vi.fn(),
}))

vi.mock('@main/workspaceDirectory.js', () => ({
// These suites spawn into synthetic paths ('/tmp/project', '/recorded/worktree')
// that intentionally do not exist on disk. The real spawn-path guard stats the
// cwd, so it is stubbed here; workspaceDirectory.test.ts covers the guard
// itself, and sessionManager.recover.test.ts overrides this mock to prove the
// manager surfaces a missing folder.
MissingWorkspaceDirectoryError: class MissingWorkspaceDirectoryError extends Error {
constructor(readonly cwd: string) {
super(`Workspace folder is missing: ${cwd}`)
this.name = 'MissingWorkspaceDirectoryError'
}
},
assertWorkspaceDirectoryExists: vi.fn(async () => {}),
}))

vi.mock('@providers/registry.main.js', () => ({
getMainProvider: () => ({ createSession, resolveTranscriptPath }),
}))
Expand Down
15 changes: 15 additions & 0 deletions src/main/sessionManager.lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,21 @@ const { createSession, deliverPrompt } = vi.hoisted(() => ({
deliverPrompt: vi.fn(),
}))

vi.mock('@main/workspaceDirectory.js', () => ({
// These suites spawn into synthetic paths ('/tmp/project', '/recorded/worktree')
// that intentionally do not exist on disk. The real spawn-path guard stats the
// cwd, so it is stubbed here; workspaceDirectory.test.ts covers the guard
// itself, and sessionManager.recover.test.ts overrides this mock to prove the
// manager surfaces a missing folder.
MissingWorkspaceDirectoryError: class MissingWorkspaceDirectoryError extends Error {
constructor(readonly cwd: string) {
super(`Workspace folder is missing: ${cwd}`)
this.name = 'MissingWorkspaceDirectoryError'
}
},
assertWorkspaceDirectoryExists: vi.fn(async () => {}),
}))

vi.mock('@providers/registry.main.js', () => ({
getMainProvider: () => ({ createSession, deliverPrompt }),
}))
Expand Down
51 changes: 51 additions & 0 deletions src/main/sessionManager.recover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ const terminalControl = vi.hoisted(() => ({
stop: vi.fn(async (): Promise<void> => {}),
}))

vi.mock('@main/workspaceDirectory.js', () => ({
// These suites spawn into synthetic paths ('/tmp/project', '/recorded/worktree')
// that intentionally do not exist on disk. The real spawn-path guard stats the
// cwd, so it is stubbed here; workspaceDirectory.test.ts covers the guard
// itself, and the missing-folder case below overrides this mock to prove the
// manager surfaces it.
MissingWorkspaceDirectoryError: class MissingWorkspaceDirectoryError extends Error {
constructor(readonly cwd: string) {
super(`Workspace folder is missing: ${cwd}`)
this.name = 'MissingWorkspaceDirectoryError'
}
},
assertWorkspaceDirectoryExists: vi.fn(async () => {}),
}))

vi.mock('@providers/registry.main.js', () => ({
getMainProvider: () => ({ createSession, deliverPrompt }),
}))
Expand Down Expand Up @@ -92,6 +107,42 @@ describe('SessionManager recover', () => {
terminalControl.stop.mockClear()
})

it('reports the missing folder instead of a generic start failure', async () => {
// The 2026-09-08 regression report: six panes came up as ERROR after their
// git worktrees were deleted, and the only text the user ever saw was
// "agent exited before it became ready for input (start-failed)". node-pty
// chdirs inside the forked child, so without the spawn-path guard the PTY
// is created successfully and the process is dead microseconds later —
// recover() returns ok:true and the failure masquerades as a readiness
// timeout. This asserts the guard converts that into a message naming the
// folder, and marks it non-retryable so Dispatch stops re-spawning it.
const { assertWorkspaceDirectoryExists } = await import('@main/workspaceDirectory.js')
const { MissingWorkspaceDirectoryError } = await import('@main/workspaceDirectory.js')
vi.mocked(assertWorkspaceDirectoryExists).mockRejectedValueOnce(
new MissingWorkspaceDirectoryError('/tmp/deleted-worktree'),
)
const { SessionManager } = await import('./sessionManager')
const manager = new SessionManager()

const result = await manager.recover({
sessionId: 'gone-folder-session',
kind: 'claude',
cwd: '/tmp/deleted-worktree',
})

expect(result).toMatchObject({
ok: false,
code: 'start-failed',
retryable: false,
message: 'Workspace folder is missing: /tmp/deleted-worktree',
})
// No backend was constructed, and nothing was left half-claimed: the guard
// runs before the spawn reservation precisely so a retry is not fenced out
// by a session that never existed.
expect(createSession).not.toHaveBeenCalled()
expect(manager.getBackendSnapshot('gone-folder-session')).toBeNull()
})

it('adopts a matching live backend without constructing another provider', async () => {
const { SessionManager } = await import('./sessionManager')
const manager = new SessionManager()
Expand Down
15 changes: 15 additions & 0 deletions src/main/sessionManager.screenGate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ const { createSession, createTerminalSession } = vi.hoisted(() => ({
createTerminalSession: vi.fn(),
}))

vi.mock('@main/workspaceDirectory.js', () => ({
// These suites spawn into synthetic paths ('/tmp/project', '/recorded/worktree')
// that intentionally do not exist on disk. The real spawn-path guard stats the
// cwd, so it is stubbed here; workspaceDirectory.test.ts covers the guard
// itself, and the missing-folder case below overrides this mock to prove the
// manager surfaces it.
MissingWorkspaceDirectoryError: class MissingWorkspaceDirectoryError extends Error {
constructor(readonly cwd: string) {
super(`Workspace folder is missing: ${cwd}`)
this.name = 'MissingWorkspaceDirectoryError'
}
},
assertWorkspaceDirectoryExists: vi.fn(async () => {}),
}))

vi.mock('@providers/registry.main.js', () => ({
getMainProvider: () => ({
name: 'Claude',
Expand Down
23 changes: 21 additions & 2 deletions src/main/sessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ import type {
SessionRecoverResult,
} from '@shared/types/session.js'
import { TmuxRegistry } from '@main/tmux/TmuxRegistry.js'
import {
MissingWorkspaceDirectoryError,
assertWorkspaceDirectoryExists,
} from '@main/workspaceDirectory.js'
import { performanceService } from '@main/performance/PerformanceService.js'
import { getToolPath, refreshToolchainFromState } from '@main/setup/toolchain.js'
import { resolveToolPath } from '@main/setup/binaryResolver.js'
Expand Down Expand Up @@ -1836,12 +1840,20 @@ export class SessionManager extends EventEmitter {
return {
ok: false,
code: 'start-failed',
retryable: true,
// A deleted folder is not retryable: every retry re-runs the same
// stat and fails identically. The incident journal for 2026-09-08
// shows the old path retrying these spawns on each Dispatch select,
// which is pure noise once the cause is known.
retryable: !(error instanceof MissingWorkspaceDirectoryError),
// WHY the raw provider exception stays out of IPC: binary launch
// errors can contain environment values, proxy URLs, or scoped MCP
// tokens. Main records the typed code and internal performance error;
// renderer receives one stable, actionable message with no payload.
message: 'Session failed to start. Check provider setup and retry.',
// The missing-directory case is the deliberate exception — its
// message is curated and its payload is a path the UI already shows.
message: error instanceof MissingWorkspaceDirectoryError
? error.message
: 'Session failed to start. Check provider setup and retry.',
}
} finally {
if (this.recoveriesInFlight.get(options.sessionId) === claim) {
Expand Down Expand Up @@ -2422,6 +2434,13 @@ export class SessionManager extends EventEmitter {
}
const kind: SessionKind = options.kind ?? DEFAULT_PROVIDER
const providerRuntime = resolveProviderRuntime(kind, options.providerRuntime)
// Fail here rather than in the forked child. See
// MissingWorkspaceDirectoryError for why a deleted cwd is otherwise
// invisible until the readiness wait times out. This runs before the
// spawn reservation so a missing folder leaves no half-claimed session
// behind, and it is in spawnWithId rather than in spawn()/recover()
// separately because this is the one funnel both of them pass through.
await assertWorkspaceDirectoryExists(options.cwd)
if (
this.sessions.has(sessionId) ||
this.spawningSessionGenerations.has(sessionId) ||
Expand Down
15 changes: 15 additions & 0 deletions src/main/sessionManager.wake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ const { createSession, createTerminalSession } = vi.hoisted(() => ({
createTerminalSession: vi.fn(),
}))

vi.mock('@main/workspaceDirectory.js', () => ({
// These suites spawn into synthetic paths ('/tmp/project', '/recorded/worktree')
// that intentionally do not exist on disk. The real spawn-path guard stats the
// cwd, so it is stubbed here; workspaceDirectory.test.ts covers the guard
// itself, and sessionManager.recover.test.ts overrides this mock to prove the
// manager surfaces a missing folder.
MissingWorkspaceDirectoryError: class MissingWorkspaceDirectoryError extends Error {
constructor(readonly cwd: string) {
super(`Workspace folder is missing: ${cwd}`)
this.name = 'MissingWorkspaceDirectoryError'
}
},
assertWorkspaceDirectoryExists: vi.fn(async () => {}),
}))

vi.mock('@providers/registry.main.js', () => ({
getMainProvider: () => ({
name: 'OpenCode',
Expand Down
Loading