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
2 changes: 1 addition & 1 deletion .memory/bot-computers.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,5 +119,5 @@ Plan: `docs/plans/bot-handoffs-rules-computers-plan.md` §8 (package P8). Branch
- Watch edges (third review of PR #559): (1) a forced stop that failed with an unclassified error ended the watch at once, and one `get` reading not-running gave up the place while the VM could still come up; the catch now keeps looping (stop again every pollMs) until the watch is over, and only then trusts that read (running or unreadable = on, returned at once as before). (2) A `run` that exits non-zero with `not_found`, `stale` or `vm_limit` sets `launch = "ended"` (checked before the post-run abort check), so it gets one forced stop and no watch; `other` and a timed-out `run` are still watched. (3) Each watch round reads the launch log tail and stops on Lume's `Error:` line (`LUME_LOG_ERROR`, shared with `runAndWait`). (4) `stopVm(vmName, force, owner = "the")` names the computer in "Aiden couldn't stop … computer."; `stopComputer` passes `possessive(botId)`, so a Bot's own retry and another Bot's start at the cap name whose computer it was (deleteVm and base callers keep "the"). Fake: `run` refuses an `AIDEN_FAKE_LUME_STALE` VM with "still being provisioned" before launching anything; the limit path honours `runReturnDelayMs` (its error is in the log before `run` returns); `stopFailsBooting` (`AIDEN_FAKE_LUME_STOP_FAILS_BOOTING`) makes `stop` of a VM still `starting` fail with "Failed to stop virtual machine"; the `__vnc` child handles SIGTERM on the event loop so a stop can't kill it between writing `running` and logging `vnc`. Tests: a third section of "…still coming up…", Ada-named messages in "a start cancelled or failing…", and "a start Lume refused, or whose launch Lume's log says failed, is turned off once…" (counts `stop` calls).
- Quit mid-launch (fourth review of PR #559, at 409660ad): with no signal, a `run` still starting up (Lume delayed before its detach branch) outlived quit's 4 s host budget, main exited, and `run` then booted the VM with nobody to turn it off. Now the host has a `quitting` AbortController that `beginQuit()` (new on BotComputerHost; `shutdown()` calls it first, before any await, and `shutdownBotComputers` calls it before the bounded viewer close) aborts; every `run` gets `quitting.signal` with `killOnAbort` (new on runBoundedProcess and runLume: SIGKILL at once instead of TERM then KILL, so a Lume that handles TERM runs nothing more). In-app cancels (Pause, Stop, every caller gone) still don't cut `run` short (the Launch window reasoning above). A `run` killed after it spawned the VM process goes through the catch's watch for what is left of quit; one that comes up later is reconcile's at the next launch. `shutdownBotComputers` bounds `viewer.close()` at 500 ms so the host's kill always runs inside main's 5 s ceiling. Fake seams `runLaunchDelayMs` (`AIDEN_FAKE_LUME_RUN_LAUNCH_DELAY_MS`, a sleep before `run` reads state or launches) and `runIgnoresTerm` (`AIDEN_FAKE_LUME_RUN_IGNORES_TERM`), and every `cli` entry logs its pid. Tests: subprocess "quitting while Lume's run is still starting up leaves no computer running after Aiden has gone" (main/services/bot-computers/test-support/quit-mid-launch.ts plays main: start, wait for `run`, shutdown(300), exit; it exits 1 at once if the start ends or `run` never begins within 30 s, and the test has a 60 s timeout and SIGKILLs the helper and its `run` in a finally; fails without the fix and with a TERM-only fix), in-process "quitting while Lume's run is launching a computer that is still coming up turns it off before Aiden has gone" (bootDelayMs 400 inside a 4 s budget, runReturnDelayMs 6 s; fails without the quit signal and if the catch skips the watch at quit) and lume-cli "killOnAbort ends a cancelled child at once…". A detached quit-time helper that keeps stopping the VM after main exits was rejected: no RunAsNode (fuse off), a shell script or Electron helper mode, cross-launch races with a quick relaunch, and it would break the packaged acceptance's no-Lume-after-quit check.
- Fake Lume state writes are atomic (temp file + rename) since PR #559 at 4282a899: an in-place `writeFileSync` let a `stop` read the file half-written as the `__vnc` child registered "running", answer "Virtual machine not found", and end the failed-start watch, so "a start cancelled while the VM Lume launched is still coming up…" failed about 3 in 104 runs under load (lines 688 and 703; Pullfrog saw it once). Never go back to writing the state in place.
- VNC password on argv: unchanged (Lume 0.6.1 takes it only as `--vnc-password`); docs Privacy → Screen states it, and Confirming on a Mac lists the `ps` check. Needs-owner.
- VNC password on argv: unchanged (Lume 0.6.1 takes it only as `--vnc-password`); docs Privacy → Screen states it, and Confirming on a Mac lists the `ps` check. Owner accepted it as a documented limit (answer 2b, 2026-10-22); revisit only when a pinned Lume takes the password another way.

2 changes: 1 addition & 1 deletion .memory/bot-handoffs-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Plan: `docs/plans/bot-handoffs-rules-computers-plan.md`, package P5. Branch `wp/
- `remote-bot-chat-view.tsx` shows the shared hand-off card (`handoff-card.tsx`, new `finishOn` prop, "Finish on <Mac>") and `BotPausedNotice` (`renderer/main/bots/bot-paused-card.tsx`).
- The chat's ••• menu has Pause Bot / Resume Bot.
- A settled card is hidden by the identity of the host's handoff object, not its wait id. Resume asks again under the same wait id, and the frame or snapshot carrying it is a new object, so the card shows again (adversarial review fix).
- The host feed sends paired desktops the revision-35 rows, so the desktop sidebar never shows a paired Bot as Paused or Interrupted (its rows show only "Working…" or the Bot's purpose). Only the open chat knows. Whether holders should get per-audience feed rows is an open owner question (hardening H2).
- Paired desktop sidebar (owner answer 3a, 2026-10-22): the host feed sends `bot:handoffs` desktops holder rows, and `remoteBotGroups` maps `sessionState` paused/interrupted to `status`, so `RemoteBotsSection` shows "Paused" or "Interrupted — tap to resume" (the local row labels from `bot-session-state.ts`) in place of the purpose. A host on an older build still sends `unavailable`, which shows the purpose.
- Merge watch with #565:
- `peer-bot-session-compat.ts` `SESSION_KEYS` must gain `handoff`.
- The registry's catch must merge into `requestGrants`.
Expand Down
2 changes: 1 addition & 1 deletion .memory/bot-pause.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Plan: `docs/plans/bot-handoffs-rules-computers-plan.md` §7. Code names say *pau
- Telegram (`bot-reply-outbox.ts`): a message for a paused Bot gets one fixed line ("{Name} is paused. Resume it in Aiden to continue.") with no outbox row and no typing action; a reply cut short by a pause is delivered after unpause + Resume.
- Remote legacy projection (`aiden-remote-bot-session.ts`): shipped phones decode a closed state enum, so `paused` → `{state: "unavailable", interrupted: false}` everywhere (snapshot keeps its transcript), and send/resume/dismiss → `operation_stale` 409, which the idempotency ledger forgets so the same key runs after unpause. The `routines_skipped` entry is dropped on the wire (default branch). Only rev-37 `bot:handoffs` devices may see `paused`/`bot_paused` (P4).
- Legacy chat route (`aiden-remote-chats.ts` `startTurn`, `POST /chats/{id}/turns`): paired desktops still drive a Mac's Bots through the Bot's canonical chat, outside the durable session. Its `botPaused(botId)` option refuses a paused Bot's chat with `operation_stale` 409 before preflight, appends or provider calls, wrapped as `AidenOperationNotExecutedError` so the same Idempotency-Key runs after unpause. Desktop wires `isBotPaused` (`bot-pause-main.ts`, re-exported by `bot-session-main.ts`; throws on an unreadable file, so the turn is refused); the CLI wires `bots.isPaused`. The route checks twice: before anything else, and again once the turn holds the chat (`beginChatTurn`), so a pause landing during model resolve or preflight still refuses it before any append.
- Legacy work already running when the Bot pauses (fix for review finding): the first pause step → `stopBotLegacyChatWork` (`bot-runtime/bot-legacy-chat-pause.ts`) → for every chat of the Bot (`chatStore.listByBot`), `llmClient.stopChatWork(chatId)`: cancels its starting and running generations as `user_stop` (tools, approvals, subagents with them), keeps the chat and its side questions, and waits (5 s grace, never throws) until nothing runs on it. Other Bots' and workspace chats are untouched. `llm-client.ts` also refuses a paused Bot's generation once it is registered ("This Bot is paused. Resume it to continue."), so a start that slips past the route's checks is either refused there or found and stopped by `stopChatWork`. The CLI's `pauseBot` already cancels and drains its Bot chats, through the daemon's own `cancelChat` (abort): the run ends `cancelled` with its partial answer and no `cancellationOrigin`, where the desktop's records `user_stop`. `docs/aiden-remote-api-v1.md` says so. On a desktop with Bots, `durableBotSessions` answers every Bot-chat turn `400 invalid_request` before either pause check, so the two `409 operation_stale` checks matter on the CLI host.
- Legacy work already running when the Bot pauses (fix for review finding): the first pause step → `stopBotLegacyChatWork` (`bot-runtime/bot-legacy-chat-pause.ts`) → for every chat of the Bot (`chatStore.listByBot`), `llmClient.stopChatWork(chatId)`: cancels its starting and running generations as `user_stop` (tools, approvals, subagents with them), keeps the chat and its side questions, and waits (5 s grace, never throws) until nothing runs on it. Other Bots' and workspace chats are untouched. `llm-client.ts` also refuses a paused Bot's generation once it is registered ("This Bot is paused. Resume it to continue."), so a start that slips past the route's checks is either refused there or found and stopped by `stopChatWork`. The CLI's `pauseBot` stops its Bot chats through the daemon's `stopChatWork`, so the run ends `cancelled` with its partial answer and `cancellationOrigin: "user_stop"`, as on the desktop (owner's choice 2026-10-22, question 4). The CLI daemon now records the first stop's origin: `user_stop` for Stop and Pause, `chat_deletion` for a deleted chat, `application_shutdown` when the daemon stops. On a desktop with Bots, `durableBotSessions` answers every Bot-chat turn `400 invalid_request` before either pause check, so the two `409 operation_stale` checks matter on the CLI host.
- Renderer: Bot ••• menu (chat header, sidebar rows, pinned Bot) and Profile ••• menu: Pause Bot / Resume Bot (after Pin). `BotPausedCard` ("Ada is paused." + Resume, neutral tone) beside the interrupted card; composer `ready=false` with "Ada is paused.". Row labels (G2): interrupted = "Interrupted — tap to resume", paused = "Paused". `RoutinesSkippedCard` lists each routine with Run now (settles to "Started"). `useBotLiveSummary(botId)` shares the sidebar's summary cache.
- CLI (`packages/cli/src/bots.ts`, via the running `aiden serve`): `CliBotGenerationRuntime.isPaused(botId)` (contract) and `assertNotPaused(botId)`; `aiden bots pause <id>` → `{botId, paused: true, since}` (writes first, then cancels and waits for the Bot's live chats), `aiden bots unpause <id>` → `{botId, paused: false}`. `preflight` (phone Bot turns) throws `operation_stale` 409 "<Name> is paused."; `daemon-chats.ts` refuses a paused Bot turn (`chat:error`, no provider request). Legacy (non-durable) CLI has no Bot delete path, so it never calls `forgetBot`. Durable CLI sessions (#577, merged into #559 on 2026-10-22): `createCliDurableBots` gets the same `pauses` store instance (`CliDurableBotPorts.pauses`, passed to `createBotSessionService`), `bots pause`/`unpause` call `durable.runtime.pause(id, "cli")`/`unpause`, so a running durable turn is suspended and left for Resume/Dismiss; durable `bots delete` forgets the pause. The CLI's `AidenRemoteBotSessionService` wires no hand-offs, so it never advertises `bot:handoffs` and phones see a paused CLI Bot as `unavailable`. `createCliDurableBots` returns `stateChanges(listener)` fed by the session service's `onStateChange`, and `remote-bots.ts` passes it to `AidenRemoteBotSessionService`, so an open phone stream hears a CLI pause/unpause on an idle Bot at once (desktop does the same through `onBotSessionStateChange`). Not fixed (main's #577): the CLI doesn't wire `durableBotSessions`, so a Bot-chat turn on a durable CLI host is `500 internal_error` (or `409` while paused), not the Mac's `400`; the shared refusal text names the Mac. Test: parity.test.mjs "a durable Bot paused from the CLI…".

Expand Down
8 changes: 7 additions & 1 deletion .memory/remote-rev37-bot-handoffs.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Plan: `docs/plans/bot-handoffs-rules-computers-plan.md`, package P4 (host side).
- `AidenRemoteBotService.sessionStates` (service-main) and `projectAidenRemoteBotSummary`, which adds `paused: true`;
- the projector's `state`;
- idempotency ledger results.
It is projected per device at the edge: the router for `/bots`, detail routes and from-preset; the host feed with `LEGACY_AUDIENCE`, so desktops always get revision-35 rows; and the session service after the ledger returns.
It is projected per device at the edge: the router for `/bots`, detail routes and from-preset; the host feed per audience (owner answer 3a, 2026-10-22): the source sends `bots` (legacy) and `handoffBots` (holder), journal entries carry `audience` (`all`/`legacy`/`handoffs`), a change both read alike is one entry, and the router passes `botHandoffs: holdsBotHandoffs(device)` to `open`; and the session service after the ledger returns.

## Session service (`aiden-remote-bot-session.ts`)
- `Audience.handoffs`. The broadcast cache key is `cards:activity:handoffs`.
Expand Down Expand Up @@ -66,3 +66,9 @@ Plan: `docs/plans/bot-handoffs-rules-computers-plan.md`, package P4 (host side).
- `routines_skipped` is sent to no device.
- A legacy phone keeps the notice line in its local list until its next snapshot.
- Remote has no Bot-routine run route. The generic schedules API already 404s Bot routines.

## Revision 40: `takesNote` (owner answer 5a, 2026-10-22)
- `AidenRemoteBotHandoff.takesNote?: false`; `toRemoteBotHandoff` sets it when `botHandoffTakesNote(prompt)` is false (the locked `credentials` rule's card). Parsers on all three clients accept the key only as `false`; absent means true.
- Opt-in `bot:handoff-notes` (Pullfrog P1 on #583, 2026-10-26): revision 37 shipped in v0.61.0, whose closed hand-off decoder refuses unknown keys, so the session service strips `takesNote` (`handoffForAudience`) unless the audience has `notes` (router `holdsBotHandoffNotes`: holds both grants). Offered with `bot:handoffs` (`AIDEN_REMOTE_BOT_HANDOFF_CAPABILITIES`). Desktop peer asks for it `own_call`; iOS (`capabilitiesAskedForAlone` group `[.botHandoffs, .botHandoffNotes]` in `negotiateCapabilityAloneIfNeeded`) and Android (`aidenBotHandoffGrantsToRequest`) ask for it in the hand-offs call only when offered, or alone once the Mac updates; both add it to the feed grants so an open chat reopens. Fixture `capability` stays `bot:handoffs`.
- `AIDEN_REMOTE_BOT_HANDOFF_NOTE_CONTRACT_REVISION = 40` is the contract revision (moved from 38 on 2026-10-27, after #588 took 38 and #585 took 39); re-claim at merge if `main` moved again.
- Fixture `botHandoffs.noNoteHandoff`. Desktop peer (`remote-bot-chat-view.tsx`), iOS (`composerHeldForNoNote`) and Android (`AidenBotComposerMode.HELD_FOR_NO_NOTE`) hold the composer ("Enter it yourself, then tap Done.") and refuse a note in `answerHandoff`.
Original file line number Diff line number Diff line change
Expand Up @@ -290,15 +290,20 @@ fun aidenBotLostAccess(error: Exception): Boolean = when (error) {
}

/**
* Which of `bot:cards`, `bot:activity` and `bot:handoffs` [installation] holds. The Mac shapes a
* Which of `bot:cards`, `bot:activity`, `bot:handoffs` and `bot:handoff-notes` [installation] holds. The Mac shapes a
* Bot session feed by these (its cards, Chat activity, hand-offs and the paused state) when the
* feed opens, and keeps that shape for the feed's life. A Bot chat can open from the pairing's
* saved features while it connects, before this connection has negotiated a grant, so the chat
* compares these once `/server` has answered and reopens a feed that opened without one
* ([AidenBotSessionController.reopenFeed]).
*/
fun aidenBotSessionFeedGrants(installation: AidenInstallation?): Set<AidenRemoteCapability> =
listOf(AidenRemoteCapability.BOT_CARDS, AidenRemoteCapability.BOT_ACTIVITY, AidenRemoteCapability.BOT_HANDOFFS)
listOf(
AidenRemoteCapability.BOT_CARDS,
AidenRemoteCapability.BOT_ACTIVITY,
AidenRemoteCapability.BOT_HANDOFFS,
AidenRemoteCapability.BOT_HANDOFF_NOTES
)
.filterTo(mutableSetOf()) { installation?.hasNegotiatedAccess(it) == true }

/** What the composer does with its text (revision 37). */
Expand All @@ -309,6 +314,11 @@ enum class AidenBotComposerMode {
HANDOFF_NOTE,
/** A sign-in hand-off waits: it is finished on the Mac, or skipped on its card. */
HELD_FOR_SIGN_IN,
/**
* Revision 40: a card a password-or-code rule opened waits. It takes no note, so the composer
* holds: a code typed here would reach the Bot. Done and Skip on the card answer it.
*/
HELD_FOR_NO_NOTE,
/** The person paused the Bot: nothing is sent until Resume. */
PAUSED
}
Expand Down Expand Up @@ -374,8 +384,10 @@ data class AidenBotSessionUiState(

/** Revision 37: the step the Bot handed back, while it waits. */
val handoff: AidenBotHandoff? get() = session?.handoff
/** The composer's text goes to an action hand-off as Done's note. */
val composerAnswersHandoff: Boolean get() = handoff?.kind == AidenBotHandoffKind.ACTION
/** The composer's text goes to an action hand-off as Done's note, unless the card takes none. */
val composerAnswersHandoff: Boolean get() = handoff?.kind == AidenBotHandoffKind.ACTION && handoff?.takesNote != false
/** Revision 40: the waiting card takes no note, so the composer waits for Done or Skip. */
val composerHeldForNoNote: Boolean get() = handoff?.kind == AidenBotHandoffKind.ACTION && handoff?.takesNote == false
/** A sign-in hand-off is finished on the Mac: the composer waits for it (Skip still answers). */
val composerHeldForSignIn: Boolean get() = handoff?.kind == AidenBotHandoffKind.SIGN_IN

Expand All @@ -384,6 +396,7 @@ data class AidenBotSessionUiState(
get() = when {
isPaused -> AidenBotComposerMode.PAUSED
composerHeldForSignIn -> AidenBotComposerMode.HELD_FOR_SIGN_IN
composerHeldForNoNote -> AidenBotComposerMode.HELD_FOR_NO_NOTE
composerAnswersHandoff -> AidenBotComposerMode.HANDOFF_NOTE
else -> AidenBotComposerMode.MESSAGE
}
Expand All @@ -393,7 +406,7 @@ data class AidenBotSessionUiState(
get() = when (composerMode) {
AidenBotComposerMode.MESSAGE -> canSend
AidenBotComposerMode.HANDOFF_NOTE -> !isAnsweringHandoff
AidenBotComposerMode.HELD_FOR_SIGN_IN, AidenBotComposerMode.PAUSED -> false
AidenBotComposerMode.HELD_FOR_SIGN_IN, AidenBotComposerMode.HELD_FOR_NO_NOTE, AidenBotComposerMode.PAUSED -> false
}

/** The transcript to draw: the Mac's, or the cached one while connecting. */
Expand Down Expand Up @@ -1508,6 +1521,8 @@ class AidenBotSessionController(
val handoff = _state.value.session?.handoff ?: return false
if (outcome == AidenBotHandoffOutcome.DONE && handoff.kind == AidenBotHandoffKind.SIGN_IN) return false
val trimmed = note?.trim()?.takeIf { it.isNotEmpty() }
// A card that takes no note (revision 40) is answered bare: a code typed as a note never leaves the phone.
if (trimmed != null && !handoff.takesNote) return false
if (trimmed != null && trimmed.codePointCount(0, trimmed.length) > AidenBotHandoffWire.MAX_NOTE_LENGTH) {
_state.update { it.copy(actionError = HANDOFF_NOTE_TOO_LONG) }
return false
Expand Down
Loading
Loading