Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
104 changes: 104 additions & 0 deletions docs/plans_and_ideas/2026-09-21-auto-update-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Agent Code auto-update — implementation plan

Status: researched 2026-09-21; implementation on `feat/auto-update`. Issue: see GitHub.
Owner of this plan: the goal loop that follows it stage by stage.

## Verified facts (not assumptions — each checked on this machine)

1. **The feed already ships.** Release v0.1.0 carries `latest-mac.yml` plus
dmg/zip and `.blockmap` pairs for arm64+x64 — electron-updater's complete
GitHub feed shape, produced by the existing pipeline.
2. **The repo is public**, so anonymous `GitHubOptions` update checks work in
the packaged app with no embedded tokens.
3. **Signing exists**: a "Developer ID Application: Julius Olsson" cert is in
the keychain, and `scripts/package-mac.mjs` is the single home of the
unsigned-when-no-cert policy (CSC_LINK/CSC_NAME; strips all three
notarization credential paths). CI (`release.yml`, workflow_dispatch with
`publish_release`/`prerelease`/`tag` inputs) validates signing secrets
before publishing.
4. **No updater code exists** (zero electron-updater references) and
**electron-builder.yml has no `publish` config**, so packaged apps do NOT
embed `app-update.yml` — the updater would have nothing to read. This is
the actual wiring gap, bigger than the client code.
5. **macOS constraint chain**: Squirrel.Mac requires the update zip to be
signed with the SAME identity as the installed app; updates apply by
replacing the app and relaunching — there is no in-place hot swap.
6. **The app has a committed-quit choreography** (#945: before-quit veto,
"Keep Editing", stopExtensions inventory, will-quit gate). Any update
application MUST ride that path — a `quitAndInstall()` that bypasses it
would hard-kill live PTYs/agents, the exact disaster this app exists to
avoid.
7. electron.build docs were unreachable from this session (404s via fetch);
implementation-time research should use web search (Brave API key provided
by the user — see loop prompt for handling rules) plus the installed
package's TypeScript definitions as the source of truth for API shape.

## A → D

**A (exists, trusted):** `release/latest-mac.yml` + signed artifacts published
to GitHub Releases by workflow; app has `buildInfo.ts`, a native app menu
(`appMenu`), extension host services, and the #945 shutdown choreography.

**D (goal):** a packaged app, on launch and on demand, checks GitHub for a
newer stable release, downloads it in the background (blockmap differential
where possible), shows a non-invasive "Restart to update" affordance, and —
only on explicit user action — applies it THROUGH the existing graceful-quit
path and relaunches. Unsigned dev builds and CI builds without publish config
no-op cleanly. First release through the new path: **v0.1.1 (patch)**.

## Stages

### B — Feed and embed verification (no product code)
- Confirm `reusable-package-release.yml`/`reusable-app-build-macos.yml`
upload `latest-mac.yml` + blockmaps for every future release (they did for
v0.1.0; make it explicit, not accidental).
- Add `publish: [{ provider: github, owner: Juliusolsson05, repo: agent-code }]`
to electron-builder.yml; verify a local package embeds
`resources/app-update.yml`. Unsigned local builds must still succeed
(package-mac policy) — embed happens regardless of signing.
- Anonymous `GET /releases/latest` asset check script (manual/CI-diagnostic).

### C — `src/main/updates/UpdateService.ts` (isolated, test-first)
- Constructor takes an injected `autoUpdater`-shaped object (real one from
`electron-updater` in production; fakes in tests) — same pattern as the
extension services' deadline/spawn injection.
- State machine: `idle → checking → (available → downloading → ready) | none`,
plus `error`. Events surface through the existing notification/toast fan-out.
- Hard rules, encoded as tests:
- NEVER auto-restarts; `autoInstallOnAppQuit = false`.
- `restartToUpdate()` routes through the #945 quit flow (the same
before-quit veto the close button uses) and only then `quitAndInstall()`.
- No-op + one log line when `!app.isPackaged` or `app-update.yml` missing.
- Signature-mismatch / network / no-update errors become actionable
user-facing strings, never stack dumps.

### D — Wiring + UX
- GitHubOptions from buildInfo/publish config; channel `latest`;
`allowDowngrade: false`; logger into the app run journal.
- App menu: "Check for Updates…" + status line (agent-code's appMenu).
- On-launch check delayed past startup-critical work; re-check at most daily
(persist last-check timestamp in existing state store).
- Download progress → quiet status; completion → "Restart to update" button.

### E — Tests, review, release
- Unit: state machine, no-restart invariant, dev no-op, error mapping.
- **Claude-agent consultations (user-requested)**: (1) policy review — when
may an update apply given live PTYs/agents; (2) electron-updater pitfalls
(mac signature identity continuity, blockmap edge cases, GitHub rate
limits) against our plan; (3) pre-merge code review.
- PR → merge after review (user pre-approved merge in the loop directive).
- Cut **v0.1.1** patch: bump package.json, run release workflow
(`publish_release=true`, `stable`), verify the release carries latest-mac.yml
+ blockmaps and that a v0.1.0 install's updater would see it (feed diff).
- Honest limits: no interactive "watch it update" proof without UI automation;
record what was verified programmatically.

## After this ships — finish Agent Code Poker loop remainder

Real Electron acceptance of the in-app LAN flow (harness or user-assisted),
promotion/closure of open gates, docs sync. Tracked by the same loop.

## Non-goals

Windows/Linux channels, nightly-channel switching UI, forced updates,
update while sessions run, embedding any token for private feeds.
9 changes: 9 additions & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
appId: com.agentcode.app
# The update feed declaration. electron-builder embeds resources/app-update.yml
# from this, which is the ONLY thing electron-updater reads at runtime — the
# GitHub workflow that actually uploads artifacts is separate and unchanged.
# Publishing still requires an explicit --publish/-p flag, so local and CI
# builds do not accidentally release anything.
publish:
- provider: github
owner: Juliusolsson05
repo: agent-code
productName: Agent Code
copyright: "Copyright © 2026 Julius Olsson"
artifactName: "${productName}-${version}-${arch}.${ext}"
Expand Down
59 changes: 58 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
"@vitest/coverage-v8": "^4.1.7",
"electron": "^43.1.0",
"electron-builder": "^26.16.1",
"electron-updater": "^6.6.2",
"electron-vite": "^5.0.0",
"esbuild": "0.25.12",
"happy-dom": "^20.9.0",
Expand Down
4 changes: 4 additions & 0 deletions src/main/applicationShutdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ interface Stage {
* with their evidence-bearing lifecycle implementations.
*/
export function installApplicationShutdown(options: {
/** Passed straight to the shutdown gate: a pending self-update REPLACES the
* final quit so Squirrel's relaunch is armed before the process exits. */
update?: Parameters<typeof installSessionShutdownGate>[0]['update']
app: QuitApp
services: ApplicationShutdownServices
prepare: () => void
Expand Down Expand Up @@ -175,6 +178,7 @@ export function installApplicationShutdown(options: {
onQuitAllowed: options.onQuitAllowed,
onShutdownError: options.onShutdownError,
...(options.platform ? { platform: options.platform } : {}),
...(options.update ? { update: options.update } : {}),
})
return gate
}
54 changes: 52 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { createExternalControlSettings } from './settings/externalControl'
import { createExternalCodexIntegration } from './settings/externalCodexIntegration'
import operatorSkillSource from '../../operator-skills/agent-code-computer-execution/SKILL.md?raw'

import { app, clipboard, crashReporter, dialog, Menu, powerMonitor, systemPreferences } from 'electron'
import { app, clipboard, crashReporter, dialog, Menu, Notification, powerMonitor, systemPreferences } from 'electron'
import { existsSync } from 'fs'
import { readFile } from 'fs/promises'
import { join } from 'path'
Expand Down Expand Up @@ -66,6 +66,7 @@ import { ExtensionRuntimeService } from '@main/extensions/runtimeService.js'
import { ExtensionCapabilityService } from '@main/extensions/capabilityService.js'
import { registerExtensionRuntimeIpc } from '@main/extensions/runtimeIpc.js'
import { ExtensionServiceHost } from '@main/extensions/serviceHost.js'
import { autoUpdater } from 'electron-updater'
import { clearServiceTransport, configureServiceTransport } from '@main/extensions/serviceTransport.js'
import { registerExtensionInputIpc } from '@main/extensions/nativeInput.js'
import { sweepAbandonedInstallDirectories } from '@main/extensions/install.js'
Expand Down Expand Up @@ -139,6 +140,8 @@ import { createFileVaultStore } from '@main/keyVault/vaultStore.js'
import { createSafeStorageCodec } from '@main/keyVault/safeStorageCodec.js'
import { VaultService } from '@main/keyVault/VaultService.js'
import { buildAppMenu } from '@main/menu/appMenu.js'
import { UpdateService } from '@main/updates/UpdateService.js'
import { UpdateCheckStore } from '@main/updates/updateCheckStore.js'
import { AppRunJournal } from '@main/incident/AppRunJournal.js'
import { installProcessCrashHooks } from '@main/incident/installCrashHooks.js'
import { installWindowIncidentHooks } from '@main/incident/installWindowIncidentHooks.js'
Expand Down Expand Up @@ -397,6 +400,29 @@ registerExtensionScheme()
let quitting = false
app.on('before-quit', () => { quitting = true })

// Self-update (issue #1120). Everything safety-critical lives in
// UpdateService; this wiring only supplies the real transport, the OS
// notification surface, the persisted check clock, and the two shutdown
// hooks: the gate swaps its final quit for quitAndInstall, and the
// Keep-Editing veto resets the intent so a retry still works.
const updateChecks = new UpdateCheckStore()
const updateService = new UpdateService({
updater: autoUpdater,
app: { isPackaged: app.isPackaged },
requestQuit: () => { app.quit() },
notify: message => {
try {
if (Notification.isSupported()) new Notification({ title: 'Agent Code', body: message }).show()
} catch { /* a notification failure must never break the update flow */ }
},
readLastCheck: () => updateChecks.read(),
// A failed clock write (full disk, read-only state) must never become an
// unhandledRejection incident report; the cost is one extra check later.
writeLastCheck: at => { void updateChecks.write(at).catch(() => {}) },
now: () => Date.now(),
log: line => { console.log(`[updates] ${line}`) },
})

const hasSingleInstanceLock = packagingSmoke || app.requestSingleInstanceLock()

if (packagingSmoke) {
Expand Down Expand Up @@ -1286,6 +1312,9 @@ async function startApp(): Promise<void> {
setWindowCloseVetoedObserver(() => {
quitting = false
extensionQuitReady = false
// Keep Editing also cancels a pending update restart: the staged update
// stays on disk, only the intent resets (UpdateService.onQuitVetoed).
updateService.onQuitVetoed()
// Announce the resume so v2 views closed by the quit's pause re-attach
// (viewBridge retries only views that failed meanwhile). Without it every
// open extension view stayed on "failed to start" after Keep Editing.
Expand Down Expand Up @@ -1440,7 +1469,15 @@ async function startApp(): Promise<void> {
})
// Install the application menu right after the window exists — the File
// items dispatch command ids to THIS window's renderer (issue #148).
Menu.setApplicationMenu(buildAppMenu())
Menu.setApplicationMenu(buildAppMenu({
// Dual duty: not-ready → force a check; ready → apply the update. The
// OS notification tells the user which state they are in.
onCheckForUpdates: () => {
if (updateService.state === 'ready') { updateService.restartToUpdate(); return 'ready' }
void updateService.checkForUpdates(true)
return 'checking'
},
}))
performanceService.mark('app.main.window.created')
// Both belong at window creation. The startup timing is observed first so
// `app.startup` keeps meaning "process start until the first window exists"
Expand Down Expand Up @@ -1489,7 +1526,20 @@ app.on('before-quit', (event) => {
// precedes renderer unload decisions, so only reversible preparation belongs
// there. In particular, Keep Editing must leave workflow/MCP/LSP/remote/voice
// services intact. The gate holds will-quit until this inventory has settled.
// Update cadence (consulted): first check at T+3min so it never competes with
// startup; every 4h; once more on wake. Timers are unref'd — the app must
// never be held alive by an update check. Dev builds short-circuit inside the
// service ('disabled'), so the timers exist but do nothing.
if (app.isPackaged) {
const firstUpdateCheck = setTimeout(() => { void updateService.checkForUpdates() }, 3 * 60 * 1000)
firstUpdateCheck.unref()
const updateCheckInterval = setInterval(() => { void updateService.checkForUpdates() }, 4 * 60 * 60 * 1000)
updateCheckInterval.unref()
powerMonitor.on('resume', () => { void updateService.checkForUpdates() })
}

const sessionShutdownGate = installApplicationShutdown({
update: { pending: () => updateService.pendingInstall(), install: () => updateService.installUpdate() },
app,
platform: process.platform,
prepare: () => {
Expand Down
25 changes: 24 additions & 1 deletion src/main/menu/appMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,15 @@ function dispatchCommand(commandId: NativeMenuCommandId): void {
sendToFocusedWindow('menu:command', commandId)
}

export function buildAppMenu(): Menu {
export function buildAppMenu(options: {
/** Self-update surface: when omitted (tests, very early startup) the items
* stay absent rather than clicking into nothing. The single item does dual
* duty: no update downloaded → force a check; update ready → apply it. A
* static menu cannot flip labels, and rebuilding the menu on updater events
* would briefly detach every accelerator; the OS notification carries the
* state change instead. */
onCheckForUpdates?: () => 'checking' | 'ready' | 'disabled'
} = {}): Menu {
const isMac = process.platform === 'darwin'

const template: MenuItemConstructorOptions[] = [
Expand Down Expand Up @@ -171,5 +179,20 @@ export function buildAppMenu(): Menu {
{ role: 'windowMenu' },
]

if (options.onCheckForUpdates) {
// App menu (macOS) / File menu (elsewhere): updates are an application
// lifecycle concern, so they sit beside Quit, not among workspace commands.
const updatesItem: MenuItemConstructorOptions = {
label: 'Check for Updates…',
click: () => { options.onCheckForUpdates?.() },
}
// The macOS appMenu is a bare role (Electron generates its submenu), so a
// lifecycle item lives at the END of File, beside the other app-level
// entries (Setup…, Close Tab) rather than inside a generated role menu.
const file = template.find(item => item.label === 'File') as { submenu?: MenuItemConstructorOptions[] } | undefined
if (file?.submenu) file.submenu.push({ type: 'separator' }, updatesItem)
else template.unshift({ label: 'Help', submenu: [updatesItem] })
}

return Menu.buildFromTemplate(template)
}
Loading
Loading