diff --git a/.memory/bot-computers.md b/.memory/bot-computers.md index 97b3adc7e..81ca4b852 100644 --- a/.memory/bot-computers.md +++ b/.memory/bot-computers.md @@ -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. diff --git a/.memory/bot-handoffs-clients.md b/.memory/bot-handoffs-clients.md index fb390a354..38f7db70e 100644 --- a/.memory/bot-handoffs-clients.md +++ b/.memory/bot-handoffs-clients.md @@ -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 ") 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`. diff --git a/.memory/bot-pause.md b/.memory/bot-pause.md index 35b043424..65e4de092 100644 --- a/.memory/bot-pause.md +++ b/.memory/bot-pause.md @@ -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 ` → `{botId, paused: true, since}` (writes first, then cancels and waits for the Bot's live chats), `aiden bots unpause ` → `{botId, paused: false}`. `preflight` (phone Bot turns) throws `operation_stale` 409 " 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…". diff --git a/.memory/remote-rev37-bot-handoffs.md b/.memory/remote-rev37-bot-handoffs.md index 4e2857995..9d7821b64 100644 --- a/.memory/remote-rev37-bot-handoffs.md +++ b/.memory/remote-rev37-bot-handoffs.md @@ -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`. @@ -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`. diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionController.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionController.kt index 8e598b353..a4aadb50b 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionController.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionController.kt @@ -290,7 +290,7 @@ 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 @@ -298,7 +298,12 @@ fun aidenBotLostAccess(error: Exception): Boolean = when (error) { * ([AidenBotSessionController.reopenFeed]). */ fun aidenBotSessionFeedGrants(installation: AidenInstallation?): Set = - 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). */ @@ -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 } @@ -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 @@ -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 } @@ -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. */ @@ -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 diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionScreen.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionScreen.kt index 15e9eecc2..606005e45 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionScreen.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionScreen.kt @@ -547,6 +547,7 @@ private fun AidenBotSessionContent( AidenBotComposerMode.MESSAGE -> aidenBotChatPlaceholder(identity.name) AidenBotComposerMode.HANDOFF_NOTE -> stringResource(R.string.bot_handoff_note_placeholder) AidenBotComposerMode.HELD_FOR_SIGN_IN -> stringResource(R.string.bot_handoff_sign_in_placeholder) + AidenBotComposerMode.HELD_FOR_NO_NOTE -> stringResource(R.string.bot_handoff_no_note_placeholder) AidenBotComposerMode.PAUSED -> stringResource(R.string.bot_paused_placeholder, identity.name) }, enabled = ui.composerEnabled, diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenRemoteCoordinator.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenRemoteCoordinator.kt index c2a197fc1..e0cf35ccd 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenRemoteCoordinator.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/features/remote/AidenRemoteCoordinator.kt @@ -91,10 +91,18 @@ internal fun aidenProgressCapabilitiesToRequest( /** * Revision 37: a Mac that offers `bot:handoffs` in `serverCapabilities` is asked for it, in a - * call of its own after the progress grants, unless [installation] already holds it. + * call of its own after the progress grants, unless [installation] already holds it. Revision 40: + * `bot:handoff-notes` joins that call only when the Mac offers it too, so a Mac on an earlier + * revision still grants hand-offs, and a phone that holds them asks for the notes alone once its Mac updates. */ +internal fun aidenBotHandoffGrantsToRequest(server: AidenServer, installation: AidenInstallation): List { + if (!server.offersBotHandoffs) return emptyList() + val offered = if (server.offersBotHandoffNotes) AidenRemoteCapability.PHONE_BOT_HANDOFFS else listOf(AidenRemoteCapability.BOT_HANDOFFS) + return offered.filterNot { server.capabilities.contains(it) || installation.hasNegotiatedAccess(it) } +} + internal fun aidenShouldRequestBotHandoffs(server: AidenServer, installation: AidenInstallation): Boolean = - aidenShouldRequestSoloCapability(AidenRemoteCapability.BOT_HANDOFFS, server, installation) + aidenBotHandoffGrantsToRequest(server, installation).isNotEmpty() /** * Revision 39: a Mac that offers `schedule:cards` in `serverCapabilities` is asked @@ -116,8 +124,8 @@ private fun aidenShouldRequestSoloCapability( internal data class AidenSoloCapabilityNegotiation(val server: AidenServer, val granted: List?) /** - * Revision 37: asks [server] for `bot:handoffs` in a call of its own (`accepts` names nothing - * else), then reads `/server` again. Returns null when nothing was asked, the Mac refused (a 400 + * Revision 37: asks [server] for `bot:handoffs` (and, revision 40, `bot:handoff-notes` when it + * offers them) in a call of its own (`accepts` names nothing else), then reads `/server` again. Returns null when nothing was asked, the Mac refused (a 400 * or 404 only means it can't grant it), the read failed, or the pairing changed while it ran * ([stillCurrent]). [AidenSoloCapabilityNegotiation.granted] is set only once the Mac's own * `serverCapabilities` lists every grant it returned. A 401 or a revoked credential is rethrown. @@ -128,7 +136,7 @@ internal suspend fun aidenRequestBotHandoffs( installation: AidenInstallation, stillCurrent: () -> Boolean = { true } ): AidenSoloCapabilityNegotiation? = - aidenRequestSoloCapability(AidenRemoteCapability.BOT_HANDOFFS, client, server, installation, stillCurrent) + aidenRequestCapabilitiesAlone(aidenBotHandoffGrantsToRequest(server, installation), client, stillCurrent) /** * Revision 39: asks [server] for `schedule:cards` alone, exactly as @@ -141,18 +149,20 @@ internal suspend fun aidenRequestScheduleCards( installation: AidenInstallation, stillCurrent: () -> Boolean = { true } ): AidenSoloCapabilityNegotiation? = - aidenRequestSoloCapability(AidenRemoteCapability.SCHEDULE_CARDS, client, server, installation, stillCurrent) + aidenRequestCapabilitiesAlone( + if (aidenShouldRequestScheduleCards(server, installation)) listOf(AidenRemoteCapability.SCHEDULE_CARDS) else emptyList(), + client, + stillCurrent + ) -private suspend fun aidenRequestSoloCapability( - capability: AidenRemoteCapability, +private suspend fun aidenRequestCapabilitiesAlone( + missing: List, client: AidenRemoteClient, - server: AidenServer, - installation: AidenInstallation, stillCurrent: () -> Boolean ): AidenSoloCapabilityNegotiation? { - if (!stillCurrent() || !aidenShouldRequestSoloCapability(capability, server, installation)) return null + if (!stillCurrent() || missing.isEmpty()) return null return try { - val complete = client.updateDeviceCapabilities(listOf(capability)) + val complete = client.updateDeviceCapabilities(missing) if (!stillCurrent()) return null val refreshed = client.server() if (!stillCurrent()) return null diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBotSession.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBotSession.kt index 206e72bf0..2ddf91e34 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBotSession.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBotSession.kt @@ -545,6 +545,7 @@ enum class AidenBotHandoffKind(val wireValue: String) { * ("Your turn: …"), on the session's `handoff` and on `handoff` frames. It is answered once by * [waitId] through `POST /bots/{botId}/handoffs/{waitId}/answer` with Done or Skip. A sign-in * is finished in the Bot's own browser on the Mac, so a phone offers Finish on your Mac and Skip. + * Revision 40: a card a password-or-code rule opened carries `takesNote: false` ([takesNote]). */ data class AidenBotHandoff( val waitId: String, @@ -553,7 +554,12 @@ data class AidenBotHandoff( val action: String, val why: String? = null, /** The https page the step happens on, opened in the phone's browser. */ - val openUrl: String? = null + val openUrl: String? = null, + /** + * Revision 40: false on a card whose answer may carry no note (a password-or-code rule opened + * it), so the composer holds instead of sending a code to the Bot as Done's note. + */ + val takesNote: Boolean = true ) { init { AidenBotHandoffWire.validateWaitId(waitId, "handoff.waitId") @@ -569,13 +575,17 @@ data class AidenBotHandoff( put("action", JsonPrimitive(action)) why?.let { put("why", JsonPrimitive(it)) } openUrl?.let { url -> put("open", buildJsonObject { put("url", JsonPrimitive(url)) }) } + if (!takesNote) put("takesNote", JsonPrimitive(false)) } companion object { private val REQUIRED = setOf("waitId", "kind", "action") - private val ALLOWED = REQUIRED + setOf("why", "open") + private val ALLOWED = REQUIRED + setOf("why", "open", "takesNote") - /** Exact keys, as the Mac's parser reads them: `why` and `open` may be left out, never null. */ + /** + * Exact keys, as the Mac's parser reads them: `why`, `open` and `takesNote` may be left + * out, never null, and `takesNote` is only ever `false`. + */ fun parse(element: JsonElement): AidenBotHandoff { val obj = element as? JsonObject ?: throw AidenBotContractException.InvalidField("handoff") if (!obj.keys.containsAll(REQUIRED) || !ALLOWED.containsAll(obj.keys)) { @@ -590,12 +600,19 @@ data class AidenBotHandoff( } val kind = AidenBotHandoffKind.fromWire(string(obj["kind"], "handoff.kind")) ?: throw AidenBotContractException.InvalidField("handoff.kind") + obj["takesNote"]?.let { value -> + val flag = value as? JsonPrimitive + if (flag == null || flag.isString || flag.content != "false") { + throw AidenBotContractException.InvalidField("handoff.takesNote") + } + } return AidenBotHandoff( waitId = string(obj["waitId"], "handoff.waitId"), kind = kind, action = string(obj["action"], "handoff.action"), why = obj["why"]?.let { string(it, "handoff.why") }, - openUrl = openUrl + openUrl = openUrl, + takesNote = !obj.containsKey("takesNote") ) } } diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenInstallation.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenInstallation.kt index 7ed9a983f..43e962ae0 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenInstallation.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenInstallation.kt @@ -163,6 +163,10 @@ data class AidenServer( val offersBotHandoffs: Boolean get() = serverCapabilities?.contains(AidenRemoteCapability.BOT_HANDOFFS) == true + /** The Mac offers `bot:handoff-notes` (revision 40). Only a Mac serving hand-offs offers it. */ + val offersBotHandoffNotes: Boolean + get() = offersBotHandoffs && serverCapabilities?.contains(AidenRemoteCapability.BOT_HANDOFF_NOTES) == true + /** Offered and held: show hand-off cards, the paused state, and Pause and Resume. */ val supportsBotHandoffs: Boolean get() = offersBotHandoffs && capabilities.contains(AidenRemoteCapability.BOT_HANDOFFS) diff --git a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/protocol/AidenRemoteProtocol.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/protocol/AidenRemoteProtocol.kt index dc7f24ad9..e35f31cd5 100644 --- a/android/app/src/main/java/sbtbiswas/AidenOnTheGo/protocol/AidenRemoteProtocol.kt +++ b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/protocol/AidenRemoteProtocol.kt @@ -549,6 +549,12 @@ data class AidenRemoteCapability(val rawValue: String) { * `serverCapabilities`, and it is asked for in a call of its own. */ val BOT_HANDOFFS = AidenRemoteCapability("bot:handoffs") + /** + * Contract revision 40: a hand-off card that takes no note says so (`takesNote`). It + * grants no authority. A Mac on an earlier revision refuses the name, so this phone asks + * for it only from a Mac that offers it, in the hand-offs call. + */ + val BOT_HANDOFF_NOTES = AidenRemoteCapability("bot:handoff-notes") /** * Contract revision 39: this phone renders the structured `schedule` card * on scheduled-task approvals. It grants no authority: the Mac attaches the card only @@ -580,8 +586,8 @@ data class AidenRemoteCapability(val rawValue: String) { /** Negotiable behind `bot-activity-v1`. */ val PHONE_BOT_ACTIVITY = listOf(BOT_ACTIVITY) - /** Negotiable when the Mac lists it in `serverCapabilities`, in its own call. */ - val PHONE_BOT_HANDOFFS = listOf(BOT_HANDOFFS) + /** Negotiable when the Mac lists them in `serverCapabilities`, in a call of their own. */ + val PHONE_BOT_HANDOFFS = listOf(BOT_HANDOFFS, BOT_HANDOFF_NOTES) /** Revision 39: negotiable when the Mac lists it in `serverCapabilities`, in its own call. */ val PHONE_SCHEDULE_CARDS = listOf(SCHEDULE_CARDS) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 67970d347..e81600c9e 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -722,6 +722,7 @@ Open %1$s Add a note and send to mark it done Finish signing in on your Mac, or skip it. + Enter it yourself, then tap Done. Finish signing in on your Mac. Open Aiden on your Mac to finish signing in. %1$s is paused. diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotHandoffsContractTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotHandoffsContractTest.kt index b6463575d..6486159a0 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotHandoffsContractTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotHandoffsContractTest.kt @@ -34,6 +34,7 @@ import sbtbiswas.AidenOnTheGo.features.bots.aidenApplyBotSessionEvent import sbtbiswas.AidenOnTheGo.features.bots.aidenBotSessionFeedGrants import sbtbiswas.AidenOnTheGo.features.remote.AidenRemoteCoordinator import sbtbiswas.AidenOnTheGo.features.remote.aidenProgressCapabilitiesToRequest +import sbtbiswas.AidenOnTheGo.features.remote.aidenBotHandoffGrantsToRequest import sbtbiswas.AidenOnTheGo.features.remote.aidenRequestBotHandoffs import sbtbiswas.AidenOnTheGo.features.remote.aidenShouldRequestBotHandoffs import sbtbiswas.AidenOnTheGo.models.* @@ -202,6 +203,15 @@ class AidenBotHandoffsContractTest { )) { refuses(handoff.with("open" to JsonObject(mapOf("url" to JsonPrimitive(url))))) } + refuses(handoff.with("takesNote" to JsonPrimitive(true))) + refuses(handoff.with("takesNote" to JsonPrimitive("false"))) + refuses(handoff.with("takesNote" to JsonNull)) + // Revision 40: a password-or-code card says it takes no note, only ever as false. + val noNote = handoffs.getValue("noNoteHandoff").jsonObject + val decodedNoNote = AidenBotHandoff.parse(noNote) + assertEquals(false, decodedNoNote.takesNote) + assertEquals(noNote, decodedNoNote.toJson()) + assertEquals(true, AidenBotHandoff.parse(handoff).takesNote) // Both optional fields may be left out. val bare = wire.decodeFromString(AidenBotSession.serializer(), session.with("handoff" to handoff.without("why").without("open")).toString()) assertEquals(AidenBotHandoff(waitId, AidenBotHandoffKind.ACTION, "Enter the 2FA code your bank sent you"), bare.handoff) @@ -295,6 +305,36 @@ class AidenBotHandoffsContractTest { assertFalse("already held", aidenShouldRequestBotHandoffs(holding, installationHolding(held))) } + @Test + fun handoffNotesAreAskedForWithHandoffsOnlyFromAMacThatOffersThem() = runBlocking { + val notes = AidenRemoteCapability.BOT_HANDOFF_NOTES + val handoffs = AidenRemoteCapability.BOT_HANDOFFS + // A Mac on revision 37 offers hand-offs only: it is never asked for the notes. + assertEquals(listOf(handoffs), aidenBotHandoffGrantsToRequest(mac(base, listOf(handoffs)), installationHolding(base))) + // A Mac that offers both is asked for both in the one call; a phone that already holds + // hand-offs (its Mac just updated) asks for the notes alone; one that holds both asks nothing. + val both = listOf(handoffs, notes) + assertEquals(both, aidenBotHandoffGrantsToRequest(mac(base, both), installationHolding(base))) + assertEquals(listOf(notes), aidenBotHandoffGrantsToRequest(mac(base + handoffs, listOf(notes)), installationHolding(base + handoffs))) + assertFalse(aidenShouldRequestBotHandoffs(mac(base + both, emptyList()), installationHolding(base + both))) + // Notes without hand-offs are not an offer. + assertTrue(aidenBotHandoffGrantsToRequest(mac(base, listOf(notes)), installationHolding(base)).isEmpty()) + + val names = base.map { it.rawValue } + val granted = names + "bot:handoffs" + "bot:handoff-notes" + server.enqueue(MockResponse().setResponseCode(200).setBody("""{"capabilities":${JsonArray(granted.map(::JsonPrimitive))}}""")) + server.enqueue(MockResponse().setResponseCode(200).setBody(serverBody(granted, granted))) + val outcome = requireNotNull(aidenRequestBotHandoffs(client, mac(base, both), installationHolding(base))) + assertEquals( + listOf("bot:handoffs", "bot:handoff-notes"), + Json.parseToJsonElement(server.takeRequest().body.readUtf8()).jsonObject.getValue("accepts").jsonArray.map { it.jsonPrimitive.content } + ) + assertEquals(base + both, outcome.granted) + val held = installationHolding(base + both) + assertTrue("a Bot chat reopens its feed once the notes are granted", aidenBotSessionFeedGrants(held).contains(notes)) + assertFalse(aidenBotSessionFeedGrants(installationHolding(base + handoffs)).contains(notes)) + } + private fun serverBody(capabilities: List, serverCapabilities: List) = """ {"protocolVersion":1,"instanceId":"test_instance","name":"Mac","appVersion":"1.0.0","connectionMode":"lan", "serverTime":"2026-10-20T09:00:00.000Z", diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionControllerTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionControllerTest.kt index 6926fd552..d963c9ab4 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionControllerTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/features/bots/AidenBotSessionControllerTest.kt @@ -2388,6 +2388,25 @@ class AidenBotSessionControllerTest { assertEquals(AidenBotSessionController.HANDOFF_EXPIRED, controller.state.value.actionError) } + @Test + fun aCardThatTakesNoNoteHoldsTheComposerAndIsAnsweredOnlyBare() = runTest { + val noNote = handoffSession.copy(handoff = handoffSession.handoff!!.copy(takesNote = false)) + val transport = HandoffTransport(noNote) + val controller = AidenBotSessionController(noNote.botId, transport, backgroundScope) + controller.refetch() + assertEquals(AidenBotComposerMode.HELD_FOR_NO_NOTE, controller.state.value.composerMode) + assertFalse("a code typed here would reach the Bot", controller.state.value.composerEnabled) + assertFalse(controller.state.value.composerAnswersHandoff) + + assertFalse(controller.answerHandoff(AidenBotHandoffOutcome.DONE, "123456")) + assertTrue("a note never leaves the phone for this card", transport.answers.isEmpty()) + assertNotEquals("the card still waits", null, controller.state.value.handoff) + + assertTrue(controller.answerHandoff(AidenBotHandoffOutcome.DONE)) + assertEquals(AidenBotHandoffAnswerRequest(AidenBotHandoffOutcome.DONE), transport.answers.single().request) + assertEquals(AidenBotComposerMode.MESSAGE, controller.state.value.composerMode) + } + @Test fun aSignInIsFinishedOnTheMacOrSkippedNeverMarkedDone() = runTest { val signIn = handoffSession.copy(handoff = handoffSession.handoff!!.copy(kind = AidenBotHandoffKind.SIGN_IN, openUrl = null)) diff --git a/docs/aiden-on-the-go-remote-access.md b/docs/aiden-on-the-go-remote-access.md index b7dec9d51..057901e40 100644 --- a/docs/aiden-on-the-go-remote-access.md +++ b/docs/aiden-on-the-go-remote-access.md @@ -75,7 +75,7 @@ The **Usage** row on the Aiden On The Go home screen reads aggregate usage from ## Bot hand-offs and pause on the phone -When the paired Mac offers it (contract revision 37), Aiden On The Go for iPhone and Android shows a step a Bot hands back to you ("Your turn: Enter the code your bank sent you") as a card in the Bot's chat. **Done** marks it done: to add a note, type it in the composer and send it. **Skip** skips the step, and **Open** opens the page it names in the phone's browser. A sign-in step is finished in the Bot's own browser on the Mac: **Finish on your Mac** brings it forward there, or you can **Skip** it. If the Bot's chat is open while the app is in the background, a notification says the Bot needs you. The Bot's profile menu offers **Pause Bot** and **Resume Bot**. A paused Bot reads "Paused" in the Bots list, and its chat shows a Resume card and takes no messages until you resume it. An interrupted Bot reads "Interrupted — tap to resume". With an older Mac, the step appears as a "needs you on your Mac" message, and you answer it on the Mac. +When the paired Mac offers it (contract revision 37), Aiden On The Go for iPhone and Android shows a step a Bot hands back to you ("Your turn: Enter the code your bank sent you") as a card in the Bot's chat. **Done** marks it done: to add a note, type it in the composer and send it. A step your password-or-code rule hands back takes no note, so the composer waits and says "Enter it yourself, then tap Done": a code typed there would reach the Bot. **Skip** skips the step, and **Open** opens the page it names in the phone's browser. A sign-in step is finished in the Bot's own browser on the Mac: **Finish on your Mac** brings it forward there, or you can **Skip** it. If the Bot's chat is open while the app is in the background, a notification says the Bot needs you. The Bot's profile menu offers **Pause Bot** and **Resume Bot**. A paused Bot reads "Paused" in the Bots list, and its chat shows a Resume card and takes no messages until you resume it. An interrupted Bot reads "Interrupted — tap to resume". With an older Mac, the step appears as a "needs you on your Mac" message, and you answer it on the Mac. ## Values read from a web page diff --git a/docs/aiden-remote-api-v1.md b/docs/aiden-remote-api-v1.md index cda1eb42b..3795331d1 100644 --- a/docs/aiden-remote-api-v1.md +++ b/docs/aiden-remote-api-v1.md @@ -48,6 +48,7 @@ Initial capability IDs: | `bot:write` | Mutate Bot-classified chats and operate their turns, attachments, streams, and approvals. Requires `bot:read` as well as the existing route capability. | | `bot:activity` | Opt-in, no authority (contract revision 30). Delivers the Bot session `activity` and `queued` frames and snapshot keys, the person's `submissionId` on their own user entries, and Bot detail `chatActivity`. Negotiable post-pairing by any device type through `POST /device/capabilities` while `/server.features` lists `bot-activity-v1` (`404 not_found` otherwise), advertised in `serverCapabilities` only then, never issued by pairing. See "Bot chat activity, revision 30". | | `bot:handoffs` | Opt-in, no authority of its own (contract revision 37). Delivers the Bot session `handoff` field and frame, the `paused` session state, Bot `paused` flags and the `bot_paused` error code, and opens the hand-off answer, open-on-Mac and pause routes, which still need `bot:read` and `bot:write`. Negotiable post-pairing by any device type through `POST /device/capabilities` while the host lists it in `serverCapabilities` (`404 not_found` on a host without the routes), never issued by pairing. There is no `/server` feature token for it. See "Bot hand-offs and pause, revision 37". | +| `bot:handoff-notes` | Opt-in, no authority of its own (contract revision 40). Delivers `takesNote: false` on a hand-off card that takes no note, to a device that also holds `bot:handoffs`. Offered and negotiable exactly when `bot:handoffs` is, never issued by pairing. A host before revision 40 refuses the name, so devices ask for it only when the host lists it. | | `schedule:cards` | Opt-in, no authority (contract revision 39). Delivers the display-only `schedule` card on scheduled-task approvals, on `GET /streams/{streamId}/approval` and on a phone's run `approval_required` events and gap snapshots. The card is sent only to a device that also holds `schedule:read`. Negotiable post-pairing by any device type through `POST /device/capabilities` while the host lists it in `serverCapabilities` (`404 not_found` on a host without stream or run approvals), never issued by pairing. There is no `/server` feature token for it. See "Scheduled-task approval cards, revision 39". | | `tasks:read` | Read the bounded chat task-progress projection and receive `task_update` events. Granted only after explicit client negotiation at pairing or post-pairing. | | `agents:read` | Read the bounded delegated-agent roster projection and receive `agents_update` events. Granted only after explicit client negotiation at pairing or post-pairing. | @@ -357,7 +358,7 @@ Edits and task actions (`pause`, `resume`, and `run`) use `If-Match`/`expectedUp These routes serve desktop controllers. Phones and tablets never receive the grants, are never told the grants exist, and see no change in any existing route, projection or fixture. Feature tokens `host-events-v1`, `run-streams-v1`, `run-control-v1` and `chat-messages-window-v1` appear in `GET /server.features` only for desktop devices (and, for the messages window, for every device once the host implements it). A host without the services answers these paths with `404 not_found` and offers none of the grants. -- `GET /host/events` (`host:events`, SSE). Event IDs are `:`; `streamId` is always `host`. The stream opens with a `snapshot` whose payload is `{epoch, sequence, summaries, workspaces, bots}`, then carries `chat.upsert`/`chat.remove`, `workspace.upsert`/`workspace.remove`, `bot.upsert`/`bot.remove` and `run.state {chatId, runId, state, unread}`. Upserts carry the same projection the matching list route returns; removals carry `{id}`. Resume with `Last-Event-ID` or `?after=`. A cursor from another epoch, or one that fell more than 1,000 events or 8 MiB behind, gets a fresh snapshot instead of a replay. A snapshot larger than 256 KiB is split into consecutive `snapshot` frames so no frame approaches the 1 MiB limit: every frame but the last carries `partial: true` and no SSE id, and only the final frame carries the id. Clients append each chunk's `summaries`, `workspaces` and `bots` and replace their host state only on the final frame; a connection lost mid-snapshot resumes from the previous cursor and receives the snapshot again. The host re-checks the device's authorization immediately before it registers a host-feed or run-stream subscription (for the feed, after the opening read), so a revocation that lands while a subscription is opening refuses it with `403 credential_revoked`. The host sends the response headers as soon as it accepts the subscription, before any event, so a resume with nothing to replay is open at once rather than at the next change or heartbeat; run streams behave the same. A 15 s heartbeat comment keeps the connection alive. The feed never scans transcripts: it is fed by the chat activity registry, the chat and workspace change hooks and the host run registry. Bot entries and Bot-chat run states reach only devices that also hold `bot:read`, which read Bots with the host-owner audience. Host-feed workspaces may carry an optional `repository {canonicalKey, relativePath}` built from cached Git data: `canonicalKey` is a credential-free `host/owner/name` (no scheme, userinfo, port or query) and `relativePath` is the POSIX path of the workspace inside the repository, `""` at the root. It is never produced by crawling the disk. +- `GET /host/events` (`host:events`, SSE). Event IDs are `:`; `streamId` is always `host`. The stream opens with a `snapshot` whose payload is `{epoch, sequence, summaries, workspaces, bots}`, then carries `chat.upsert`/`chat.remove`, `workspace.upsert`/`workspace.remove`, `bot.upsert`/`bot.remove` and `run.state {chatId, runId, state, unread}`. Upserts carry the same projection the matching list route returns; removals carry `{id}`. Resume with `Last-Event-ID` or `?after=`. A cursor from another epoch, or one that fell more than 1,000 events or 8 MiB behind, gets a fresh snapshot instead of a replay. A snapshot larger than 256 KiB is split into consecutive `snapshot` frames so no frame approaches the 1 MiB limit: every frame but the last carries `partial: true` and no SSE id, and only the final frame carries the id. Clients append each chunk's `summaries`, `workspaces` and `bots` and replace their host state only on the final frame; a connection lost mid-snapshot resumes from the previous cursor and receives the snapshot again. The host re-checks the device's authorization immediately before it registers a host-feed or run-stream subscription (for the feed, after the opening read), so a revocation that lands while a subscription is opening refuses it with `403 credential_revoked`. The host sends the response headers as soon as it accepts the subscription, before any event, so a resume with nothing to replay is open at once rather than at the next change or heartbeat; run streams behave the same. A 15 s heartbeat comment keeps the connection alive. The feed never scans transcripts: it is fed by the chat activity registry, the chat and workspace change hooks and the host run registry. Bot entries and Bot-chat run states reach only devices that also hold `bot:read`, which read Bots with the host-owner audience. Since contract revision 37 a desktop that also holds `bot:handoffs` reads a paused Bot as `sessionState: "paused"` with `paused: true`; other desktops read it as `unavailable`. Host-feed workspaces may carry an optional `repository {canonicalKey, relativePath}` built from cached Git data: `canonicalKey` is a credential-free `host/owner/name` (no scheme, userinfo, port or query) and `relativePath` is the POSIX path of the workspace inside the repository, `""` at the root. It is never produced by crawling the disk. - `GET /chats/{chatId}/runs/current/events` and `GET /runs/{runId}/events` (`runs:observe`, SSE). They reuse the `/streams` envelope with `streamId` set to the run ID, and add `run.started {runId, chatId, origin}` (`renderer` | `remote` | `headless`), `approval_resolved {approvalId, decision?}`, `question_resolved {promptId, outcome?}` and a terminal `run.ended {runId, chatId, state}`. `run.ended` repeats the sequence of the last event, so clients must not drop it as a duplicate. Event IDs are decimal sequences and the `aiden-run-epoch` response header names the run journal epoch. Resume with `Last-Event-ID` on `/runs/{runId}/events`; the current-run route accepts no cursor. A cursor behind retention yields a `snapshot {runId, chatId, reason: "gap", epoch, state, pendingApprovalIds, pendingQuestionIds, approvals, questions, nextSequence}` at sequence `nextSequence - 1`; a journal reset yields `snapshot {runId, chatId, reason: "reset", nextSequence}`. An unknown or evicted run is `404 run_gone`. Observers without `runs:control` receive `approval_required` without tool details. - `POST /runs/{runId}/cancel`, `POST /runs/{runId}/approvals/{approvalId}/respond`, `POST /runs/{runId}/questions/{promptId}/respond` and `POST /runs/{runId}/inputs` (`runs:control`). Each takes an `application/json` body, which for cancel is always `{}`, and requires an `Idempotency-Key` of 16 to 128 visible ASCII characters; a replay within ten minutes returns the original result or error, and the ledger does not survive a restart. The run must still be the one named (`409 operation_stale` otherwise, `404 run_gone` once it is evicted). Approvals and questions are first-responder-wins across every surface: a loser receives `409 approval_resolved` or `409 question_already_resolved` with the winning `decision` or `outcome` and `resolvedAt`. Controllers see full tool details; allowing an approval whose details were withheld is `403 capability_denied`. Cancel returns `202`. Revocation closes the device's host feeds and run streams; it never cancels a run. - `GET /chats/{chatId}/messages?before=&limit=` (`chat:read`, `chat-messages-window-v1`). Returns `{chatId, revision, messages, hasOlder}`, oldest first, ending just before `before` (or at the newest message). `limit` defaults to 50 and is at most 200; a page never exceeds 1 MiB. An unknown `before` is `409 revision_conflict`. `GET /chats/{chatId}` is unchanged. Since contract revision 23 (`chat-messages-window-metadata-v1`) every page also carries the chat metadata a whole-chat read returns: `workspaceId`, optional `botId`, `title`, `titlePending` (only when `true`), `providerId`/`modelId` (both or neither), `createdAt` and `updatedAt`. Phones refresh an open transcript from the newest window only when the host advertises both tokens, so a desktop rename or model change reaches them without a whole-chat read; without the metadata token they keep the whole-chat read. @@ -768,9 +769,9 @@ A host announces `chat-visual-viewer-v1` when it ships the viewer bundle (`npm r - Native hosts load it with no base URL, deny every other navigation and window, and turn off file access and persistent storage. The page talks to them only through iOS `window.webkit.messageHandlers.aidenVisual` or Android `window.AidenVisualBridge`, with `{"type":"ready"}`, `{"type":"prompt","text":…}` (at most 2,000 characters, one per 3 s) and `{"type":"openUrl","url":"https://…"}`. A prompt is never sent by itself: the phone shows its own confirmation and only adds the text to the composer. A link opens only after the phone asks. - Visual entries are unchanged, so older phones keep showing snapshots. Phones may cache a document per host, chat, visual, scheme and theme and show it while the Mac is unreachable. -## Bot hand-offs and pause, revision 37 (`bot:handoffs`, provisional) +## Bot hand-offs and pause, revision 37 (`bot:handoffs`) -Revision 37 is provisional and is re-claimed at merge, after whatever `main` holds then. The host keeps it in one constant (`AIDEN_REMOTE_BOT_HANDOFFS_CONTRACT_REVISION`). +The host keeps the revision in one constant (`AIDEN_REMOTE_BOT_HANDOFFS_CONTRACT_REVISION`). Revision 40 (provisional; re-claimed at merge after whatever `main` holds then, in `AIDEN_REMOTE_BOT_HANDOFF_NOTE_CONTRACT_REVISION`) adds `takesNote` to the card, below, behind its own opt-in, `bot:handoff-notes`. Revision 37 shipped in v0.61.0, whose closed card decoder refuses a key it does not know, so the host sends `takesNote` only to a device that holds both `bot:handoffs` and `bot:handoff-notes`; every other holder gets the revision 37 card, and the password-or-code rule still refuses a note sent with its answer. A host lists `bot:handoff-notes` in `serverCapabilities` exactly when it lists `bot:handoffs`, and changing what a device holds changes its session streams only when they next open. A Bot can hand a step back to the person ("Your turn: Enter the 2FA code your bank sent you"), and the person can pause a Bot. Phones that opt in answer hand-offs and pause or unpause a Bot; other devices keep their revision-35 view. @@ -779,26 +780,26 @@ A Bot can hand a step back to the person ("Your turn: Enter the 2FA code your ba **Before revision 37, and for every device without `bot:handoffs`.** - A Bot the person paused reads as `unavailable` (`interrupted: false`); the snapshot keeps its transcript. Messages, Resume and Dismiss for it return `409 operation_stale`. Bot summaries and details carry `sessionState: "unavailable"` and no `paused`. - A waiting hand-off is the newest entry of the snapshot: an assistant `message` with id `handoff-notice:` and text ` needs you on your Mac: `, without `createdAt`. On the stream it arrives as an `entry` frame at the seq a holder's `handoff` frame uses. When the hand-off settles (answered, skipped or withdrawn) or another takes its place, that seq is a `snapshot` of the session as a fresh read shows it: no stream frame removes an entry, and a `snapshot` replaces a client's whole transcript, so the old line goes and a replacement's line is the newest entry again. The person must answer it on the Mac. -- On a host that does not serve `bot-durable-session-v1` (the CLI host until durable Bot sessions are enabled), `POST /chats/{chatId}/turns` on a paused Bot's chat is `409 operation_stale` for every device, also when the pause lands while the turn is being admitted. It records nothing, so the same `Idempotency-Key` runs once the Bot is unpaused. A Mac serving durable sessions answers `400 invalid_request` to every Bot-chat turn, paused or not (see "Bots, Bot conversations, access, and avatars"). A turn already streaming in a Bot's chat when the Bot pauses is stopped where it is: its run ends `cancelled` and keeps the partial answer. A Mac records `cancellationOrigin: "user_stop"` on it, as for Stop; the CLI host records no `cancellationOrigin`. A CLI host with durable Bot sessions pauses them as a Mac does, and, since it doesn't serve `bot:handoffs`, every device sees the paused Bot as `unavailable`. It doesn't give Bot-chat turns the Mac's `400` answer: a turn for a paused Bot is `409 operation_stale`, and one for any other Bot is `500 internal_error`. +- On a host that does not serve `bot-durable-session-v1` (the CLI host until durable Bot sessions are enabled), `POST /chats/{chatId}/turns` on a paused Bot's chat is `409 operation_stale` for every device, also when the pause lands while the turn is being admitted. It records nothing, so the same `Idempotency-Key` runs once the Bot is unpaused. A Mac serving durable sessions answers `400 invalid_request` to every Bot-chat turn, paused or not (see "Bots, Bot conversations, access, and avatars"). A turn already streaming in a Bot's chat when the Bot pauses is stopped where it is: its run ends `cancelled` and keeps the partial answer. Both a Mac and the CLI host record `cancellationOrigin: "user_stop"` on it, as for Stop. A CLI host with durable Bot sessions pauses them as a Mac does, and, since it doesn't serve `bot:handoffs`, every device sees the paused Bot as `unavailable`. It doesn't give Bot-chat turns the Mac's `400` answer: a turn for a paused Bot is `409 operation_stale`, and one for any other Bot is `500 internal_error`. - A pause or unpause made on the Mac, from a phone, or with `aiden bots pause` on a CLI host reaches every open session stream at once as a `state` frame, at the same seq for every device. A pause reads `unavailable` to these devices and `paused` to holders. **Session stream, for devices holding `bot:handoffs`.** - `state` may be `paused` (`interrupted: false`). `interrupted` still means a turn that stopped part-way and waits for Resume or Dismiss; a Bot can be paused with such a turn, and reads `interrupted` again after unpause. -- The snapshot always carries `handoff`: `{ waitId, kind: "action" | "sign_in", action, why?, open?: { url } }` or `null`. `action` is at most 160 characters, `why` at most 400, and `open.url` an https URL without credentials, at most 2,048 characters. The host sends `open.url` as the WHATWG URL parser prints it (lowercase scheme and host, default port dropped, punycode host, percent-encoded path), so a client with a stricter check reads the same page the Mac would open; a bare origin keeps its form, and a page whose printed form is not a valid `open.url` is sent without `open`. At most one hand-off waits per Bot. +- The snapshot always carries `handoff`: `{ waitId, kind: "action" | "sign_in", action, why?, open?: { url }, takesNote? }` or `null`. Since revision 40, `takesNote` is present, and `false`, only on a card whose answer may carry no note (one the Bot's password-or-code rule opened), and only for a device that holds `bot:handoff-notes`. Clients decode a missing `takesNote` as `true`, and refuse any other value. `action` is at most 160 characters, `why` at most 400, and `open.url` an https URL without credentials, at most 2,048 characters. The host sends `open.url` as the WHATWG URL parser prints it (lowercase scheme and host, default port dropped, punycode host, percent-encoded path), so a client with a stricter check reads the same page the Mac would open; a bare origin keeps its form, and a page whose printed form is not a valid `open.url` is sent without `open`. At most one hand-off waits per Bot. - A `handoff` frame with payload `{ handoff: {…} | null }` is published after `approval` when the waiting hand-off changes. - Answering a hand-off puts the person's side in the transcript for **every** device: a user `message` reading `You did this: `, `You skipped this: ` or `You signed in to `, followed by a blank line and the person's note when they wrote one. **Routes.** Each needs `bot:read`, `bot:write` and `bot:handoffs` (`403 capability_denied` otherwise; `404 not_found` on a host without them) and an `Idempotency-Key` matching `^[\x21-\x7e]{16,128}$`. A repeat with the same key replays the first result. A refusal (`409 handoff_expired`, `400 invalid_request`) records nothing for its key, so a repeat is checked again and gets the answer that is true then. The Bot session's question answers (`POST /bots/{botId}/questions/{waitId}/answer`) and approval answers (`POST /approvals/{id}/respond`) treat their refusals (`409 question_expired`, `403 capability_denied`, `400 invalid_request` and the like) the same way. -- `POST /bots/{botId}/handoffs/{waitId}/answer` with `{ outcome: "done" | "skipped", note? }` (a note is not blank and at most 4,000 characters) returns `{ waitId }`. A hand-off that is no longer waiting is `409 handoff_expired`. A malformed answer, and `done` on a `sign_in` hand-off (signing in happens on the Mac, in the Bot's browser or on the Bot's own computer), are `400 invalid_request`. So is any note with the answer to a hand-off the Bot's password-or-code rule opened: that card takes no note, so a password or code typed as one never reaches the Bot. The snapshot doesn't say which rule opened a card, so a holder that sends a note there gets the `400` and the hand-off keeps waiting for Done or Skip. +- `POST /bots/{botId}/handoffs/{waitId}/answer` with `{ outcome: "done" | "skipped", note? }` (a note is not blank and at most 4,000 characters) returns `{ waitId }`. A hand-off that is no longer waiting is `409 handoff_expired`. A malformed answer, and `done` on a `sign_in` hand-off (signing in happens on the Mac, in the Bot's browser or on the Bot's own computer), are `400 invalid_request`. So is any note with the answer to a hand-off the Bot's password-or-code rule opened: that card takes no note, so a password or code typed as one never reaches the Bot. Since revision 40 the card says so with `takesNote: false`, and clients hold their composer for it rather than send a note; a note sent anyway still gets the `400`, and the hand-off keeps waiting for Done or Skip. - `POST /bots/{botId}/handoffs/{waitId}/open-on-mac` with `{}` brings the hand-off forward on the Mac (the Bot's chat, and for a sign-in the Bot's own browser when it has one, or the Bot's own computer, taken over for the person, when the sign-in names it) and returns `{ waitId, opened }`. A hand-off that is no longer waiting is `409 handoff_expired`. - `POST /bots/{botId}/pause` with `{}` pauses the Bot and returns the session state (`paused`). A running turn is closed and left interrupted; its question, hand-off and approval are withdrawn and asked again after unpause and Resume. Pausing a paused Bot keeps the first pause. - `DELETE /bots/{botId}/pause`, without a body, clears the pause and returns the session state. - While a Bot is paused, a message, Resume or Dismiss from a holder is `409 bot_paused`, which records nothing for its key. - The routines that came due while the Bot was paused are listed on the Mac after unpause (the `routines_skipped` entry); this revision sends that entry to no device. -**Bot summaries and detail.** See "Bot" in section 4: holders get `paused` and `sessionState: "paused"`; the host feed for paired desktops always carries the revision-35 rows. +**Bot summaries and detail.** See "Bot" in section 4: holders get `paused` and `sessionState: "paused"`. The host feed follows the same split: a paired desktop holding `bot:handoffs` gets holder rows in its snapshot and `bot.upsert` events, so its sidebar can show a paused Bot as paused, and any other desktop gets the revision-35 rows. A change both audiences read alike is one event; a pause or resume is one event for each audience. -**Clients.** Aiden On The Go for iOS and Android, and a Mac opening a paired Mac's Bot, ask for `bot:handoffs` only when the host lists it in `serverCapabilities`, in a `POST /device/capabilities` call of its own after their other negotiations, and treat a `400` or `404` as a host that does not offer it (the pairing keeps its other grants). The phones keep the grant only once the refreshed `/server` lists it; a Mac keeps the grants named in the host's `/device/capabilities` answer, and one whose call answers `401` counts the offer as unanswered and asks again on its next connection. Because the host fixes a session stream's audience when the stream opens, a Bot chat opened while the device is still negotiating reopens its stream once the grants it holds change, so a hand-off card, cards and activity reach it without leaving the chat. Holders show the waiting hand-off as a "Your turn: " card beside the question and approval cards. An `action` card offers Done, Skip and Open (for its `open.url`), and a note typed into the composer is sent as Done with that note, under the same conditions as the card's own Done (on iOS, only while connected). With photos or files staged, Send keeps the draft and says to send them after answering the card. A card a rule opened for a step in the Bot's own browser carries no `open`: its page is the Bot's tab on the Mac, and the device's browser would act on the person's own session. A `sign_in` card offers Finish on your Mac (`open-on-mac`) and Skip, and never sends `done`; a Mac titles it "Sign in to for ". Every answer is sent under one Idempotency-Key per answer (its wait id, outcome and note), which is reused while the outcome is unknown, and a `409 handoff_expired` removes the card. A Mac shows the card, and a question or approval it answered, again when Resume asks the same wait id again; while its answer to the card is on its way, its composer sends no second answer. Pause Bot and Resume Bot (the phone's Bot profile menu, the desktop chat's ••• menu) use `POST` and `DELETE /bots/{botId}/pause`. A paused chat shows Resume and holds the composer. A phone enables Resume only while it may change Bots and is connected; a Mac whenever no pause or unpause of its own is on its way, also while the stream reconnects. No client shows a pause or an unpause before the host confirms it. Tapping a hand-off alert on a host serving durable sessions opens the Bot's chat where the card waits. Phones read a paused Bot's list row as "Paused" and an interrupted one as "Interrupted — tap to resume", and post one local notification when a hand-off arrives for a Bot chat that is open but not on screen. Clients decode a missing `paused` as `false` and do not write it back. +**Clients.** Aiden On The Go for iOS and Android, and a Mac opening a paired Mac's Bot, ask for `bot:handoffs` only when the host lists it in `serverCapabilities`, in a `POST /device/capabilities` call of its own after their other negotiations (with `bot:handoff-notes` in the same call when the host lists that too; a device that already holds `bot:handoffs` asks for the notes alone once its host offers them; a Mac asks for each in a call of its own), and treat a `400` or `404` as a host that does not offer it (the pairing keeps its other grants). The phones keep the grant only once the refreshed `/server` lists it; a Mac keeps the grants named in the host's `/device/capabilities` answer, and one whose call answers `401` counts the offer as unanswered and asks again on its next connection. Because the host fixes a session stream's audience when the stream opens, a Bot chat opened while the device is still negotiating reopens its stream once the grants it holds change, so a hand-off card, cards and activity reach it without leaving the chat. Holders show the waiting hand-off as a "Your turn: " card beside the question and approval cards. An `action` card offers Done, Skip and Open (for its `open.url`), and a note typed into the composer is sent as Done with that note, under the same conditions as the card's own Done (on iOS, only while connected). For a card with `takesNote: false` (revision 40) the composer holds instead, reading "Enter it yourself, then tap Done." on the phones and naming why on a Mac, and Done and Skip answer it without a note. With photos or files staged, Send keeps the draft and says to send them after answering the card. A card a rule opened for a step in the Bot's own browser carries no `open`: its page is the Bot's tab on the Mac, and the device's browser would act on the person's own session. A `sign_in` card offers Finish on your Mac (`open-on-mac`) and Skip, and never sends `done`; a Mac titles it "Sign in to for ". Every answer is sent under one Idempotency-Key per answer (its wait id, outcome and note), which is reused while the outcome is unknown, and a `409 handoff_expired` removes the card. A Mac shows the card, and a question or approval it answered, again when Resume asks the same wait id again; while its answer to the card is on its way, its composer sends no second answer. Pause Bot and Resume Bot (the phone's Bot profile menu, the desktop chat's ••• menu) use `POST` and `DELETE /bots/{botId}/pause`. A paused chat shows Resume and holds the composer. A phone enables Resume only while it may change Bots and is connected; a Mac whenever no pause or unpause of its own is on its way, also while the stream reconnects. No client shows a pause or an unpause before the host confirms it. Tapping a hand-off alert on a host serving durable sessions opens the Bot's chat where the card waits. Phones read a paused Bot's list row as "Paused" and an interrupted one as "Interrupted — tap to resume", and post one local notification when a hand-off arrives for a Bot chat that is open but not on screen. Clients decode a missing `paused` as `false` and do not write it back. **Fixtures.** `protocol/aiden-remote/v1/fixtures/contract.json` holds `botHandoffs`: a session waiting on a hand-off, its frames, the answer, open-on-Mac, pause and unpause shapes, paused session, summary, detail and refusal, and the same moments as a device without `bot:handoffs` sees them (`legacy`). The desktop parser reads the section only when `contractRevision` is 37 or newer. diff --git a/docs/bot-computers.md b/docs/bot-computers.md index 48ea24690..59399f079 100644 --- a/docs/bot-computers.md +++ b/docs/bot-computers.md @@ -233,6 +233,10 @@ the screen keeps working. `lume run` process's arguments could also open the screen on `127.0.0.1`. Whether macOS shows those arguments to other accounts is checked under "Confirming on a Mac"; passing the password another way needs a Lume change. + This is an accepted limit (owner decision, 2026-10-22): a program that can + already read this Mac's process list runs as you, and the password lasts + only until the computer stops. Aiden revisits it when a pinned Lume can take + the password another way. - **SSH.** Aiden reaches the guest with its own key, kept in Aiden's data folder, and the guest's pinned host key (one per base Aiden built; a Bot computer presents the key of the base it was copied from). Password login is turned off in the diff --git a/docs/bot-rules.md b/docs/bot-rules.md index 845467ad6..fbac973a3 100644 --- a/docs/bot-rules.md +++ b/docs/bot-rules.md @@ -102,8 +102,11 @@ Grant connection tools only from servers you trust to describe them honestly. the person set, so the Bot doesn't run any step its rules would check, and is told "This Bot's rules couldn't be read, so this action didn't run." The rules editor shows the defaults with an alert, and the next change starts - again from them and replaces the file. A file that can't be read at all + again from them and replaces the file. Before replacing it, Aiden keeps the + damaged bytes beside it as `rules.json.unreadable` (then + `rules.json.unreadable.2`, and so on, never over an earlier copy), so rules + the person wrote can be recovered by hand. A file that can't be read at all (permissions, a directory in its place) fails closed the same way, and the editor offers Try again. -- Deleting the Bot removes the file and refuses later writes. +- Deleting the Bot removes the file and its kept copies, and refuses later writes. - Phones don't show rules yet. diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift index 051812135..725020e7d 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift @@ -883,14 +883,18 @@ struct AidenBotSessionChatView: View { /// Revision 37: a paused Bot takes nothing, and a waiting sign-in is /// finished on the Mac or skipped, so the composer waits for either. + /// Revision 40: so does a password-or-code card, which takes no note. private func composerIsHeld(_ model: AidenBotSessionModel) -> Bool { - model.isPaused || model.handoff?.kind == .signIn + model.isPaused || model.handoff?.kind == .signIn || model.composerHeldForNoNote } private func composerPlaceholder(_ model: AidenBotSessionModel) -> String { if model.isPaused { return AidenBotSessionCopy.pausedPlaceholder(bot?.name ?? "This Bot") } switch model.handoff?.kind { - case .action: return AidenBotSessionCopy.handoffNotePlaceholder + case .action: + return model.composerHeldForNoNote + ? AidenBotSessionCopy.handoffNoNotePlaceholder + : AidenBotSessionCopy.handoffNotePlaceholder case .signIn: return AidenBotSessionCopy.signInPlaceholder case nil: return "Message" } @@ -1017,8 +1021,9 @@ struct AidenBotSessionChatView: View { @ViewBuilder private func composerControls(_ model: AidenBotSessionModel) -> some View { if model.isRunning { - if model.composerAnswersHandoff { - // A waiting step's note goes with a plain Send, never Steer or Queue. + if model.composerAnswersHandoff || composerIsHeld(model) { + // A waiting step's note goes with a plain Send, never Steer or Queue, + // and a held composer offers neither. sendButton(model) } else if model.offersBusyModes { busyPill(model) diff --git a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift index 89ff34f5d..0ccf5ad02 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift @@ -46,7 +46,8 @@ func aidenBotActivitySupported(coordinator: AidenRemoteCoordinator) -> Bool { aidenBotActivitySupported(server: coordinator.server, installation: coordinator.installationStore.activeInstallation) } -/// Which of `bot:cards`, `bot:activity` and `bot:handoffs` the pairing holds. +/// Which of `bot:cards`, `bot:activity`, `bot:handoffs` and `bot:handoff-notes` +/// the pairing 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 @@ -54,7 +55,7 @@ func aidenBotActivitySupported(coordinator: AidenRemoteCoordinator) -> Bool { /// the chat reopens a feed that opened without one /// (`AidenBotSessionModel.reopenFeed()`; Android parity). func aidenBotSessionFeedGrants(_ installation: AidenInstallation?) -> Set { - Set([AidenRemoteCapability.botCards, .botActivity, .botHandoffs].filter { installation?.hasNegotiatedAccess(to: $0) == true }) + Set([AidenRemoteCapability.botCards, .botActivity, .botHandoffs, .botHandoffNotes].filter { installation?.hasNegotiatedAccess(to: $0) == true }) } /// How a Bot chat is opened. A host advertising `bot-durable-session-v1` @@ -221,6 +222,8 @@ enum AidenBotSessionCopy { static let handoffOpen = "Open" static let handoffNotePlaceholder = "Add a note and send to mark it done" static let signInPlaceholder = "Finish signing in on your Mac, or skip it." + /// Revision 40: a password-or-code card takes no note, so the composer waits. + static let handoffNoNotePlaceholder = "Enter it yourself, then tap Done." static let handoffExpired = "That card was already answered or closed." static let handoffNotSent = "That answer wasn’t sent. Please try again." static let handoffNoteTooLong = "Keep the note under 4,000 characters." @@ -2018,8 +2021,13 @@ final class AidenBotSessionModel { } /// While a step (not a sign-in) waits, the composer's text is its note: - /// Send marks the step done (desktop parity). - var composerAnswersHandoff: Bool { handoff?.kind == .action } + /// Send marks the step done (desktop parity). A card that takes no note + /// (revision 40) holds the composer instead (`composerHeldForNoNote`). + var composerAnswersHandoff: Bool { handoff?.kind == .action && handoff?.takesNote != false } + + /// Revision 40: a password-or-code card waits. A code typed in the + /// composer would reach the Bot, so the composer waits for Done or Skip. + var composerHeldForNoNote: Bool { handoff?.kind == .action && handoff?.takesNote == false } /// Answers the waiting hand-off: Done or Skip, with an optional note. A /// sign-in is finished on the Mac, so a phone can only skip it. The card @@ -2033,6 +2041,8 @@ final class AidenBotSessionModel { guard canAnswerHandoff, let shown = handoff else { return false } if outcome == .done, shown.kind == .signIn { return false } let trimmed = rawNote?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + // A card that takes no note is answered bare: a code typed as a note never leaves the phone. + if !trimmed.isEmpty, !shown.takesNote { return false } guard trimmed.unicodeScalars.count <= AidenBotHandoffWire.maxNoteLength, let request = try? AidenBotHandoffAnswerRequest(outcome: outcome, note: trimmed.isEmpty ? nil : trimmed) else { errorMessage = AidenBotSessionCopy.handoffNoteTooLong diff --git a/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift b/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift index 8e67ccaea..20cff1a9e 100644 --- a/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift +++ b/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift @@ -822,9 +822,9 @@ final class AidenRemoteCoordinator { installationId: installation.id, generation: generation ) - for capability in Self.capabilitiesAskedForAlone { + for capabilities in Self.capabilitiesAskedForAlone { await negotiateCapabilityAloneIfNeeded( - capability, + capabilities, client: client, installationId: installation.id, generation: generation @@ -834,8 +834,12 @@ final class AidenRemoteCoordinator { /// Opt-ins a Mac older than their revision refuses by name, in the order /// they are asked for after the progress batch: `bot:handoffs` (revision - /// 37), then `schedule:cards` (revision 39). - static let capabilitiesAskedForAlone: [AidenRemoteCapability] = [.botHandoffs, .scheduleCards] + /// 37), with `bot:handoff-notes` (revision 40) in the same call when the + /// Mac offers it, then `schedule:cards` (revision 39). + static let capabilitiesAskedForAlone: [[AidenRemoteCapability]] = [ + [.botHandoffs, .botHandoffNotes], + [.scheduleCards], + ] /// Existing pairings predate the progress opt-in field. Upgrade them only /// after the authenticated server advertises the matching feature. A @@ -897,22 +901,29 @@ final class AidenRemoteCoordinator { /// only while the Mac lists them in `serverCapabilities`. A Mac that does /// not know a name refuses the whole request with a 400, so naming it in /// the batch could cost the device every other opt-in. A refusal leaves the - /// earlier grants as they are. + /// earlier grants as they are. Revision 40: the rest of a group, such as + /// `bot:handoff-notes`, joins its first opt-in's call only while the Mac + /// offers that first one and lists the member too, so a Mac on an earlier + /// revision still grants `bot:handoffs`, and a device that holds it asks + /// for the notes alone once its Mac updates. private func negotiateCapabilityAloneIfNeeded( - _ capability: AidenRemoteCapability, + _ capabilities: [AidenRemoteCapability], client: AidenRemoteClient, installationId: String, generation: Int ) async { guard isCurrentContext(installationId: installationId, generation: generation), let server = self.server, server.instanceId == installationId, - server.serverCapabilities?.contains(capability) == true, - let installation = installationStore.installations.first(where: { $0.id == installationId }), - !installation.hasNegotiatedAccess(to: capability) else { + let first = capabilities.first, server.serverCapabilities?.contains(first) == true, + let installation = installationStore.installations.first(where: { $0.id == installationId }) else { return } + let missing = capabilities.filter { + server.serverCapabilities?.contains($0) == true && !installation.hasNegotiatedAccess(to: $0) + } + guard !missing.isEmpty else { return } do { - let negotiatedCapabilities = try await client.updateDeviceCapabilities(accepts: [capability]) + let negotiatedCapabilities = try await client.updateDeviceCapabilities(accepts: missing) guard isCurrentContext(installationId: installationId, generation: generation) else { return } let refreshedServer = try await client.server() guard isCurrentContext(installationId: installationId, generation: generation), diff --git a/ios/AidenOnTheGo/Models/AidenBotSession.swift b/ios/AidenOnTheGo/Models/AidenBotSession.swift index f4a84d55a..e2f6d450e 100644 --- a/ios/AidenOnTheGo/Models/AidenBotSession.swift +++ b/ios/AidenOnTheGo/Models/AidenBotSession.swift @@ -605,6 +605,8 @@ enum AidenBotHandoffKind: String, Codable, Sendable { /// The hand-off a Bot waits on (`handoff` on the session and `handoff` frames). /// Answered once, by `waitId`: Done or Skip, with an optional note. A sign-in /// is finished on the Mac, so the phone offers Finish on your Mac and Skip. +/// Revision 40: a card a password-or-code rule opened carries +/// `takesNote: false` (`takesNote`), so it is answered without a note. struct AidenRemoteBotHandoff: Codable, Equatable, Sendable { struct Open: Codable, Equatable, Sendable { let url: String @@ -629,8 +631,18 @@ struct AidenRemoteBotHandoff: Codable, Equatable, Sendable { let action: String let why: String? let open: Open? + /// Revision 40: false on a card whose answer may carry no note, so the + /// composer holds instead of sending a code to the Bot as Done's note. + let takesNote: Bool - init(waitId: String, kind: AidenBotHandoffKind, action: String, why: String? = nil, open: Open? = nil) throws { + init( + waitId: String, + kind: AidenBotHandoffKind, + action: String, + why: String? = nil, + open: Open? = nil, + takesNote: Bool = true + ) throws { try AidenBotHandoffWire.validateWaitID(waitId) try AidenBotWire.validateString(action, field: "action", maxLength: AidenBotHandoffWire.maxActionLength, allowEmpty: false) guard !AidenBotHandoffWire.isBlank(action) else { throw AidenBotContractError.invalidField("action") } @@ -642,17 +654,22 @@ struct AidenRemoteBotHandoff: Codable, Equatable, Sendable { self.action = action self.why = why self.open = open + self.takesNote = takesNote } init(from decoder: Decoder) throws { - try AidenBotWire.requireOnlyKeys(decoder, allowed: ["waitId", "kind", "action", "why", "open"]) + try AidenBotWire.requireOnlyKeys(decoder, allowed: ["waitId", "kind", "action", "why", "open", "takesNote"]) let values = try decoder.container(keyedBy: CodingKeys.self) + // Only ever `false` when present (revision 40). + let takesNote = try AidenBotWire.optional(Bool.self, from: values, forKey: .takesNote) + if takesNote == true { throw AidenBotContractError.invalidField("takesNote") } try self.init( waitId: try values.decode(String.self, forKey: .waitId), kind: try values.decode(AidenBotHandoffKind.self, forKey: .kind), action: try values.decode(String.self, forKey: .action), why: try AidenBotWire.optional(String.self, from: values, forKey: .why), - open: try AidenBotWire.optional(Open.self, from: values, forKey: .open) + open: try AidenBotWire.optional(Open.self, from: values, forKey: .open), + takesNote: takesNote == nil ) } @@ -663,10 +680,11 @@ struct AidenRemoteBotHandoff: Codable, Equatable, Sendable { try values.encode(action, forKey: .action) try values.encodeIfPresent(why, forKey: .why) try values.encodeIfPresent(open, forKey: .open) + if !takesNote { try values.encode(false, forKey: .takesNote) } } private enum CodingKeys: String, CodingKey { - case waitId, kind, action, why, open + case waitId, kind, action, why, open, takesNote } } diff --git a/ios/AidenOnTheGo/Models/AidenInstallation.swift b/ios/AidenOnTheGo/Models/AidenInstallation.swift index f15af5047..cc287b0c0 100644 --- a/ios/AidenOnTheGo/Models/AidenInstallation.swift +++ b/ios/AidenOnTheGo/Models/AidenInstallation.swift @@ -348,6 +348,7 @@ final class AidenInstallationStore { let progress = Set([ AidenRemoteCapability.tasksRead, .agentsRead, .questionsRespond, .skillsInvoke, .runsObserve, .runsControl, .simulatorsMobile, .botCards, .botActivity, .botHandoffs, + .botHandoffNotes, .appControlsRead, .appControlsRespond, .scheduleCards, ]) let existingNonProgress = Set(installations[index].deviceCapabilities).subtracting(progress) diff --git a/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift b/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift index 7e4362da2..3f99d58d1 100644 --- a/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift +++ b/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift @@ -1056,6 +1056,7 @@ final class AidenRemoteClient: @unchecked Sendable { let allowed = Set([ AidenRemoteCapability.tasksRead, .agentsRead, .questionsRespond, .skillsInvoke, .runsObserve, .runsControl, .simulatorsMobile, .botCards, .botActivity, .botHandoffs, + .botHandoffNotes, .appControlsRead, .appControlsRespond, .scheduleCards, ]) guard !accepts.isEmpty, diff --git a/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift b/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift index 28907014d..d603ef6b8 100644 --- a/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift +++ b/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift @@ -670,11 +670,12 @@ struct AidenRemoteCapability: RawRepresentable, Codable, Hashable, Sendable { /// Contract revision 37 adds `bot:handoffs`, asked for in a call of its own. /// Contract revision 39 adds `schedule:cards`, also asked for alone, after /// any other negotiation. + /// Contract revision 40 adds `bot:handoff-notes`, asked for with `bot:handoffs`. static let phoneNegotiable: [Self] = v1Known + [ Self(rawValue: "runs:observe"), Self(rawValue: "runs:control"), Self(rawValue: "simulators:mobile"), Self(rawValue: "bot:cards"), Self(rawValue: "bot:activity"), Self(rawValue: "bot:handoffs"), - Self(rawValue: "schedule:cards"), + Self(rawValue: "schedule:cards"), Self(rawValue: "bot:handoff-notes"), ] /// Revision 27: the device renders `memory_update` and `routine_proposal` @@ -692,6 +693,11 @@ struct AidenRemoteCapability: RawRepresentable, Codable, Hashable, Sendable { /// that names it, so the device asks for it alone. static let botHandoffs = Self(rawValue: "bot:handoffs") + /// Revision 40: a hand-off card that takes no note says so (`takesNote`). + /// It grants no authority. A Mac on an earlier revision refuses the name, so the + /// device asks for it only from a Mac that offers it. + static let botHandoffNotes = Self(rawValue: "bot:handoff-notes") + /// Revision 39: the device receives the display-only /// `schedule` card on scheduled-task approvals, which the host adds only /// while the device also holds `schedule:read`. It grants no authority. A diff --git a/ios/AidenOnTheGoTests/AidenBotContractTests.swift b/ios/AidenOnTheGoTests/AidenBotContractTests.swift index ce9cf52cf..e37b24984 100644 --- a/ios/AidenOnTheGoTests/AidenBotContractTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotContractTests.swift @@ -2192,6 +2192,13 @@ final class AidenBotContractTests: XCTestCase { XCTAssertTrue(decodes(AidenRemoteBotHandoff.self, card)) XCTAssertTrue(decodes(AidenRemoteBotHandoff.self, card.filter { $0.key != "why" && $0.key != "open" }), "why and open are optional") XCTAssertTrue(decodes(AidenRemoteBotHandoff.self, card.merging(["kind": "sign_in"]) { $1 })) + // Revision 40: a password-or-code card says it takes no note, and only ever as false. + let noNote = try XCTUnwrap(raw["noNoteHandoff"] as? [String: Any]) + let decodedNoNote = try AidenRemoteJSONDecoder.decode(AidenRemoteBotHandoff.self, from: JSONSerialization.data(withJSONObject: noNote)) + XCTAssertFalse(decodedNoNote.takesNote) + XCTAssertTrue(try AidenRemoteJSONDecoder.decode(AidenRemoteBotHandoff.self, from: JSONSerialization.data(withJSONObject: card)).takesNote) + let reencoded = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(decodedNoNote)) as? [String: Any]) + XCTAssertEqual(reencoded["takesNote"] as? Bool, false) // The Mac stores a page as its WHATWG `href`, which keeps characters // such as `[`, `]` and `|` unescaped; the phone shows that card too. let jiraPage = "https://jira.example.com/browse?filter[status]=open&q=a|b" @@ -2199,6 +2206,9 @@ final class AidenBotContractTests: XCTestCase { let refused: [(String, [String: Any])] = [ ("an unknown key", card.merging(["origin": "https://bank.example.com"]) { $1 }), ("an unknown kind", card.merging(["kind": "approve"]) { $1 }), + ("takesNote as true", card.merging(["takesNote": true]) { $1 }), + ("takesNote as text", card.merging(["takesNote": "false"]) { $1 }), + ("takesNote as null", card.merging(["takesNote": NSNull()]) { $1 }), ("a blank action", card.merging(["action": " "]) { $1 }), ("a long action", card.merging(["action": String(repeating: "a", count: 161)]) { $1 }), ("a long why", card.merging(["why": String(repeating: "a", count: 401)]) { $1 }), diff --git a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift index 57d67f15e..d4d0d48ee 100644 --- a/ios/AidenOnTheGoTests/AidenBotSessionTests.swift +++ b/ios/AidenOnTheGoTests/AidenBotSessionTests.swift @@ -4754,6 +4754,29 @@ final class AidenBotSessionHandoffTests: XCTestCase { XCTAssertEqual(model.errorMessage, "That card was already answered or closed.") } + func testACardThatTakesNoNoteHoldsTheComposerAndIsAnsweredOnlyBare() async throws { + let (model, transport) = await loadedModel(try session { object in + var card = try XCTUnwrap(object["handoff"] as? [String: Any]) + card["takesNote"] = false + object["handoff"] = card + }) + XCTAssertEqual(model.handoff?.takesNote, false) + XCTAssertFalse(model.composerAnswersHandoff, "a code typed in the composer would reach the Bot") + XCTAssertTrue(model.composerHeldForNoNote) + + let fromComposer = await model.answerHandoffFromComposer("123456", connected: true) + XCTAssertFalse(fromComposer) + let withNote = await model.answerHandoff(.done, note: "123456") + XCTAssertFalse(withNote) + XCTAssertTrue(transport.answers.isEmpty, "a note never leaves the phone for this card") + XCTAssertEqual(model.handoff?.waitId, waitID, "the card still waits") + + let done = await model.answerHandoff(.done) + XCTAssertTrue(done) + XCTAssertEqual(transport.answers.first?.request, try AidenBotHandoffAnswerRequest(outcome: .done)) + XCTAssertFalse(model.composerHeldForNoNote) + } + func testASignInIsFinishedOnTheMacOrSkippedNeverMarkedDone() async throws { let (model, transport) = await loadedModel(try session { object in var card = try XCTUnwrap(object["handoff"] as? [String: Any]) diff --git a/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift b/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift index 6289612cf..0da0d30d2 100644 --- a/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift +++ b/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift @@ -4440,6 +4440,86 @@ final class AidenRemoteClientTests: XCTestCase { } } + /// Contract revision 40: a Mac that offers `bot:handoff-notes` is asked + /// for it with `bot:handoffs`; a Mac on revision 37 is never asked for it, + /// and once that Mac updates, the device asks for the notes alone. + @MainActor + func testCoordinatorAsksForHandoffNotesOnlyFromAMacThatOffersThem() async throws { + for macUpdates in [false, true] { + let keychain = AidenRemoteMemoryKeychain() + let store = AidenInstallationStore(keychain: keychain) + _ = try store.savePairing( + makeExchange(instanceId: "instance-notes", deviceId: "device-notes", credential: "credential-notes"), + trust: makeSystemTrust(), + name: "Notes Mac" + ) + let session = makeSession() + var granted = ["server:read", "workspace:read"] + var accepts: [[String]] = [] + var offersNotes = !macUpdates + AidenRemoteMockURLProtocol.handler = { request in + switch (request.httpMethod, request.url?.path) { + case ("GET", "/api/aiden/v1/server"): + let serverObject: [String: Any] = [ + "protocolVersion": 1, + "instanceId": "instance-notes", + "name": "Notes Mac", + "appVersion": "1.0.0", + "capabilities": granted, + "serverCapabilities": ["server:read", "workspace:read", "tasks:read", "bot:handoffs"] + + (offersNotes ? ["bot:handoff-notes"] : []), + "features": ["chat-tasks-v1"], + "connectionMode": "lan", + "serverTime": "2026-10-26T12:00:00.000Z", + ] + return Self.response(for: request, status: 200, data: try JSONSerialization.data(withJSONObject: serverObject)) + case ("GET", "/api/aiden/v1/workspaces"): + return Self.response(for: request, status: 200, json: "{\"workspaces\":[]}") + case ("PATCH", "/api/aiden/v1/device/identity"): + let name = try XCTUnwrap(try Self.jsonBody(request)["name"] as? String) + return Self.response(for: request, status: 200, data: try JSONSerialization.data(withJSONObject: ["name": name])) + case ("POST", "/api/aiden/v1/device/capabilities"): + let asked = try XCTUnwrap(try Self.jsonBody(request)["accepts"] as? [String]) + accepts.append(asked) + granted += asked.filter { !granted.contains($0) } + return Self.response(for: request, status: 200, data: try JSONSerialization.data(withJSONObject: ["capabilities": granted])) + default: + XCTFail("Unexpected request: \(request.httpMethod ?? "nil") \(request.url?.path ?? "nil")") + return Self.response(for: request, status: 500, json: "{}") + } + } + let connect: @MainActor () async -> Void = { + let coordinator = AidenRemoteCoordinator( + installationStore: AidenInstallationStore(keychain: keychain), + clientFactory: { installation, credential in + AidenRemoteClient(endpoint: installation.endpoint, credential: credential, session: session) + } + ) + await coordinator.start() + XCTAssertEqual(coordinator.connectionState, .connected, "macUpdates: \(macUpdates)") + } + await connect() + if macUpdates { + XCTAssertEqual(accepts, [["tasks:read"], ["bot:handoffs"]], "a revision 37 Mac is never asked for notes") + let before = try XCTUnwrap(AidenInstallationStore(keychain: keychain).activeInstallation) + XCTAssertFalse(aidenBotSessionFeedGrants(before).contains(.botHandoffNotes)) + offersNotes = true + accepts = [] + await connect() + XCTAssertEqual(accepts, [["bot:handoff-notes"]], "after the update, only the notes are missing") + } else { + XCTAssertEqual(accepts, [["tasks:read"], ["bot:handoffs", "bot:handoff-notes"]], "notes ride with hand-offs") + } + let installation = try XCTUnwrap(AidenInstallationStore(keychain: keychain).activeInstallation) + XCTAssertTrue(installation.hasNegotiatedAccess(to: .botHandoffs)) + XCTAssertTrue(installation.hasNegotiatedAccess(to: .botHandoffNotes)) + XCTAssertTrue( + aidenBotSessionFeedGrants(installation).contains(.botHandoffNotes), + "a Bot chat reopens its feed once the notes are granted" + ) + } + } + /// Contract revision 39: `schedule:cards` is asked for only when the Mac /// offers it, alone and after every other opt-in, and a Mac that answers /// 400 or 404 leaves the device's other grants in place. diff --git a/ios/AidenOnTheGoTests/AidenRemotePhase0Tests.swift b/ios/AidenOnTheGoTests/AidenRemotePhase0Tests.swift index fa0580a84..e5d9b0b84 100644 --- a/ios/AidenOnTheGoTests/AidenRemotePhase0Tests.swift +++ b/ios/AidenOnTheGoTests/AidenRemotePhase0Tests.swift @@ -148,7 +148,7 @@ final class AidenRemotePhase0Tests: XCTestCase { from: data ) - XCTAssertEqual(fixture.contractRevision, 39) + XCTAssertEqual(fixture.contractRevision, 40) XCTAssertEqual(fixture.scheduleCards?.capability, .scheduleCards) // Revision 19 run-control losers learn the winning decision; phones keep // their mobile-only grants, so the fixture never offers host capabilities. diff --git a/main/services/aiden-remote-bot-handoffs.test.ts b/main/services/aiden-remote-bot-handoffs.test.ts index 8e172ebdf..c4c29db10 100644 --- a/main/services/aiden-remote-bot-handoffs.test.ts +++ b/main/services/aiden-remote-bot-handoffs.test.ts @@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import test from "node:test"; import type { BotPendingHandoff } from "../../renderer/shared/bot-handoffs.js"; +import { BOT_LOCKED_RULES } from "../../renderer/shared/bot-rules.js"; import { LEGACY_AUDIENCE, legacyHandoffNotice, @@ -46,6 +47,23 @@ test("a holder receives the waiting card exactly as the shared fixture shows it" assert.deepEqual(toRemoteBotHandoff(prompt()), fixture.session.handoff); }); +test("a card the password-or-code rule opened tells a holder it takes no note; the Bot's own cards and other rules' do not", async () => { + const fixture = await section(); + const [credentials, money] = BOT_LOCKED_RULES; + const ruled = (id: string) => + toRemoteBotHandoff(prompt({ + waitId: fixture.noNoteHandoff.waitId, + action: fixture.noNoteHandoff.action, + why: undefined, + open: undefined, + source: "rule", + rule: { id, label: "Enter passwords or codes" }, + })); + assert.deepEqual(ruled(credentials!.id), fixture.noNoteHandoff); + assert.equal("takesNote" in ruled(money!.id), false); + assert.equal("takesNote" in toRemoteBotHandoff(prompt()), false); +}); + test("a hand-off link that is not a plain https page, and a blank reason, never reach a phone", () => { for (const url of [ "http://bank.example.com/verify", diff --git a/main/services/aiden-remote-bot-handoffs.ts b/main/services/aiden-remote-bot-handoffs.ts index 8fd79d6e0..5ff86f7cf 100644 --- a/main/services/aiden-remote-bot-handoffs.ts +++ b/main/services/aiden-remote-bot-handoffs.ts @@ -7,7 +7,12 @@ // the revision-35 view: a paused Bot reads as unavailable, and a waiting // hand-off reads as one assistant line asking the person to go to the Mac. -import { isBotHandoffOpenUrl, normalizeBotHandoffAction, type BotPendingHandoff } from "../../renderer/shared/bot-handoffs.js"; +import { + botHandoffTakesNote, + isBotHandoffOpenUrl, + normalizeBotHandoffAction, + type BotPendingHandoff, +} from "../../renderer/shared/bot-handoffs.js"; import { parseAidenRemoteBotHandoff, type AidenRemoteBotDetail, @@ -49,7 +54,8 @@ function cardPage(url: string): string | undefined { * A waiting hand-off card as a `bot:handoffs` device sees it. A link that is * not a safe https page is left out, and so is a rule card's page: it is the * Bot's own tab on the Mac, and in the device's browser the step would act on - * the person's own session. + * the person's own session. A card that takes no note says so (revision 40), + * so the device hides its note box. */ export function toRemoteBotHandoff(prompt: BotPendingHandoff): AidenRemoteBotHandoff { const url = prompt.source === "rule" || prompt.open === undefined ? undefined : cardPage(prompt.open.url); @@ -60,6 +66,7 @@ export function toRemoteBotHandoff(prompt: BotPendingHandoff): AidenRemoteBotHan action: prompt.action, ...(why ? { why: prompt.why } : {}), ...(url !== undefined ? { open: { url } } : {}), + ...(botHandoffTakesNote(prompt) ? {} : { takesNote: false }), }); } diff --git a/main/services/aiden-remote-bot-session.test.ts b/main/services/aiden-remote-bot-session.test.ts index f501a77c0..730553895 100644 --- a/main/services/aiden-remote-bot-session.test.ts +++ b/main/services/aiden-remote-bot-session.test.ts @@ -30,6 +30,7 @@ import { createBotHandoffs } from "./bot-runtime/bot-handoffs.js"; import { signInCandidate } from "./bot-runtime/bot-sign-in-tool.js"; import type { BotHandoffOpened } from "./bot-runtime/bot-handoff-open.js"; import type { BotPendingHandoff } from "../../renderer/shared/bot-handoffs.js"; +import { BOT_LOCKED_RULES } from "../../renderer/shared/bot-rules.js"; import { applyRemoteBotSessionEvent, remoteBotSessionView, type RemoteBotSessionView } from "../../renderer/lib/hosts/remote-bot-session.js"; import { createBotQuestions } from "./bot-runtime/bot-questions.js"; import { createBotTurnIntents } from "./bot-runtime/bot-turn-intents.js"; @@ -1412,7 +1413,7 @@ function activityBots() { /** Serve one Bot's SSE stream to a device with these opt-ins; keep the raw data lines too. */ async function openAudienceStream( service: AidenRemoteBotSessionService, - audience: { cards?: boolean; activity?: boolean; handoffs?: boolean }, + audience: { cards?: boolean; activity?: boolean; handoffs?: boolean; handoffNotes?: boolean }, botId = BOT_ID, ) { const server = createServer((_request, response) => { @@ -2817,6 +2818,42 @@ test("a waiting hand-off reads as the shared fixture shows it, to holders and to } }); +test("only a device that also asked for hand-off notes is told a password card takes no note; a rev-37 holder gets the card it can decode", async () => { + const fixture = (await contractFixture()).botHandoffs; + const { service, handOff } = handoffSession(fakeRuntime({ kind: "running", submissionId: "sub_1" }).runtime); + const NOTES = { handoffs: true, handoffNotes: true }; + const holder = await openAudienceStream(service, HOLDER); + const notes = await openAudienceStream(service, NOTES); + try { + await holder.until(() => holder.frames.length >= 1, "the holder snapshot"); + await notes.until(() => notes.frames.length >= 1, "the notes snapshot"); + handOff({ + waitId: fixture.noNoteHandoff.waitId, + action: fixture.noNoteHandoff.action, + why: undefined, + open: undefined, + source: "rule", + rule: { id: BOT_LOCKED_RULES[0]!.id, label: "Enter passwords or codes" }, + }); + const { takesNote: _takesNote, ...rev37Card } = fixture.noNoteHandoff; + + assert.deepEqual((await service.session(BOT_ID, NOTES)).handoff, fixture.noNoteHandoff); + assert.deepEqual((await service.session(BOT_ID, HOLDER)).handoff, rev37Card); + // Asking for notes without hand-offs is still a legacy device. + assert.equal("handoff" in (await service.session(BOT_ID, { handoffNotes: true })), false); + + const card = (stream: typeof holder) => stream.frames.find((frame) => frame.type === "handoff")?.payload.handoff; + await holder.until(() => card(holder) !== undefined, "the holder's hand-off frame"); + await notes.until(() => card(notes) !== undefined, "the notes hand-off frame"); + assert.deepEqual(card(notes), fixture.noNoteHandoff); + assert.deepEqual(card(holder), rev37Card); + } finally { + await holder.close(); + await notes.close(); + await service.close(); + } +}); + test("a hand-off, its answer and a pause reach holders and other devices frame for frame as the fixture shows, at the same seqs", async () => { const fixture = (await contractFixture()).botHandoffs; const driven = drivenConversation(scoutConversation()); diff --git a/main/services/aiden-remote-bot-session.ts b/main/services/aiden-remote-bot-session.ts index 1df9a9430..1584e3523 100644 --- a/main/services/aiden-remote-bot-session.ts +++ b/main/services/aiden-remote-bot-session.ts @@ -660,6 +660,18 @@ interface Audience { activity: boolean; /** `bot:handoffs`: the `handoff` frame and snapshot key, and the `paused` state (revision 37). */ handoffs: boolean; + /** `bot:handoff-notes` beside it: a card that takes no note says so (revision 40). */ + notes: boolean; +} + +/** + * A hand-off as `audience` reads it: `takesNote` only for a device that holds + * `bot:handoff-notes`. v0.61.0 holds `bot:handoffs` and rejects the key. + */ +function handoffForAudience(handoff: AidenRemoteBotHandoff | null, audience: Audience): AidenRemoteBotHandoff | null { + if (handoff === null || audience.notes || handoff.takesNote === undefined) return handoff; + const { takesNote: _takesNote, ...card } = handoff; + return card; } interface Subscriber extends Audience { @@ -812,7 +824,7 @@ class BotLiveProjector { snapshot(audience: Audience): AidenRemoteBotSession { const showActivity = audience.activity && this.activityDeps !== undefined; const state = stateViewForAudience(this.state, audience); - const handoff = this.handoff?.handoff ?? null; + const handoff = handoffForAudience(this.handoff?.handoff ?? null, audience); let used = 2 + memberBytes("botId", this.botId) + memberBytes("epoch", this.epoch) + memberBytes("seq", this.seq); for (const [key, value] of Object.entries(state)) used += memberBytes(key, value); used += memberBytes("question", this.question) + memberBytes("approval", this.approval); @@ -876,7 +888,7 @@ class BotLiveProjector { // A stalled client is dropped; it reconnects and gets a fresh snapshot. subscriber.overflowed = true; } else { - const key = `${subscriber.cards}:${subscriber.activity}:${subscriber.handoffs}`; + const key = `${subscriber.cards}:${subscriber.activity}:${subscriber.handoffs}:${subscriber.notes}`; let text = rendered.get(key); if (text === undefined) { text = render(subscriber); @@ -935,6 +947,11 @@ class BotLiveProjector { if (legacy === "snapshot") return this.snapshotFrame(audience); return legacy ? sseFrame(id, "entry", { ...data, type: "entry", payload: { entry: legacy } }) : noOp(audience); } + if (handoffOnly) { + const handoff = (event.payload as { handoff: AidenRemoteBotHandoff | null }).handoff; + const shown = handoffForAudience(handoff, audience); + return shown === handoff ? text : sseFrame(id, "handoff", { ...data, payload: { handoff: shown } }); + } if (event.type === "state" && !audience.handoffs) { const payload = stateViewForAudience(event.payload as AidenRemoteBotSessionStateView, audience); return payload === event.payload ? text : sseFrame(id, "state", { ...data, payload }); @@ -1408,7 +1425,7 @@ function routineError(error: unknown): never { const HOLDER_AUDIENCE: AidenRemoteBotHandoffAudience = { handoffs: true }; /** The opt-ins a route was called with, from the device's grants. */ -type AudienceOptions = { cards?: boolean; activity?: boolean; handoffs?: boolean }; +type AudienceOptions = { cards?: boolean; activity?: boolean; handoffs?: boolean; handoffNotes?: boolean }; /** Ledger route of each session control. */ const CONTROL_ROUTES = { @@ -1921,6 +1938,7 @@ export class AidenRemoteBotSessionService { cards: options.cards === true, activity: options.activity === true && this.supportsActivity, handoffs: options.handoffs === true && this.supportsHandoffs, + notes: options.handoffs === true && options.handoffNotes === true && this.supportsHandoffs, }; } diff --git a/main/services/aiden-remote-host-feed.test.ts b/main/services/aiden-remote-host-feed.test.ts index 001ddfca4..f5341fc87 100644 --- a/main/services/aiden-remote-host-feed.test.ts +++ b/main/services/aiden-remote-host-feed.test.ts @@ -344,6 +344,100 @@ test("Bots and Bot-chat run states reach only devices that also hold bot:read", } }); +test("a paused Bot reads as paused only on desktops that hold bot:handoffs", async () => { + const { feed, state } = harness(); + const idle = { id: "bot-1", name: "Planner", sessionState: "idle" }; + state.current = { ...state.current, bots: [idle], handoffBots: [idle] }; + const legacy = await open(feed, desktop("device-legacy", ["bot:read"])); + const holderDevice = { ...desktop("device-holder", ["bot:read", "bot:handoffs"]), botHandoffs: true }; + const holder = await open(feed, holderDevice); + const botEvents = (response: RecordingResponse) => + response.frames().slice(1).map((frame) => [frame.event, frame.data.payload]); + const lastId = (response: RecordingResponse) => response.frames().slice(-1)[0]!.id; + try { + const beforePause = lastId(holder); + state.current = { + ...state.current, + bots: [{ ...idle, sessionState: "unavailable" }], + handoffBots: [{ ...idle, sessionState: "paused", paused: true }], + }; + feed.invalidate(); + await feed.refresh(); + assert.deepEqual(botEvents(legacy), [["bot.upsert", { ...idle, sessionState: "unavailable" }]]); + assert.deepEqual(botEvents(holder), [["bot.upsert", { ...idle, sessionState: "paused", paused: true }]]); + + // A Bot both audiences read alike is one change for each, not two. + const renamed = { ...idle, name: "Renamed" }; + state.current = { ...state.current, bots: [renamed], handoffBots: [renamed] }; + feed.invalidate(); + await feed.refresh(); + assert.deepEqual(botEvents(legacy).slice(1), [["bot.upsert", renamed]]); + assert.deepEqual(botEvents(holder).slice(1), [["bot.upsert", renamed]]); + + // A holder resuming from before the pause replays only its own rows. + const resumed = await open(feed, holderDevice, beforePause); + assert.deepEqual( + resumed.frames().map((frame) => [frame.event, frame.data.payload]), + botEvents(holder), + ); + + state.current = { + ...state.current, + bots: [{ ...renamed, sessionState: "unavailable" }], + handoffBots: [{ ...renamed, sessionState: "paused", paused: true }], + }; + feed.invalidate(); + await feed.refresh(); + const snapshotBots = (response: RecordingResponse) => (response.frames()[0]!.data.payload as { bots: unknown[] }).bots; + assert.deepEqual(snapshotBots(await open(feed, holderDevice)), [{ ...renamed, sessionState: "paused", paused: true }]); + assert.deepEqual( + snapshotBots(await open(feed, desktop("device-legacy-2", ["bot:read"]))), + [{ ...renamed, sessionState: "unavailable" }], + ); + + state.current = { ...state.current, bots: [], handoffBots: [] }; + feed.invalidate(); + await feed.refresh(); + assert.deepEqual(botEvents(legacy).slice(-1)[0], ["bot.remove", { id: "bot-1" }]); + assert.deepEqual(botEvents(holder).slice(-1)[0], ["bot.remove", { id: "bot-1" }]); + assert.equal(botEvents(holder).filter(([event]) => event === "bot.remove").length, 1); + } finally { + feed.close(); + } +}); + +test("a desktop that gains bot:handoffs on reconnect gets a fresh snapshot instead of replaying its old Bot rows", async () => { + const { feed, state } = harness(); + const bot = { id: "bot-1", name: "Planner" }; + state.current = { ...state.current, bots: [{ ...bot, sessionState: "unavailable" }], handoffBots: [{ ...bot, sessionState: "paused", paused: true }] }; + const legacyDevice = desktop("device-1", ["bot:read"]); + const first = await open(feed, legacyDevice); + const cursor = first.frames().slice(-1)[0]!.id; + try { + // The same desktop reconnects holding bot:handoffs, with the cursor it kept; the Bot hasn't changed. + const upgradedDevice = { ...desktop("device-1", ["bot:read", "bot:handoffs"]), botHandoffs: true }; + const upgraded = await open(feed, upgradedDevice, cursor); + const frames = upgraded.frames(); + assert.equal(frames[0]!.event, "snapshot"); + assert.deepEqual((frames[0]!.data.payload as { bots: unknown[] }).bots, [{ ...bot, sessionState: "paused", paused: true }]); + + // That snapshot never fully arrived, so the desktop kept its old cursor: trying again still snapshots. + const retried = (await open(feed, upgradedDevice, cursor)).frames(); + assert.equal(retried[0]?.event, "snapshot"); + assert.deepEqual((retried[0]!.data.payload as { bots: unknown[] }).bots, [{ ...bot, sessionState: "paused", paused: true }]); + + // Back without it, the cursor from the holder's snapshot doesn't replay either. + const back = await open(feed, legacyDevice, retried.slice(-1)[0]!.id); + assert.deepEqual((back.frames()[0]!.data.payload as { bots: unknown[] }).bots, [{ ...bot, sessionState: "unavailable" }]); + + // A cursor kept on the same rows still resumes without a snapshot. + const resumed = await open(feed, legacyDevice, back.frames().slice(-1)[0]!.id); + assert.equal(resumed.frames().some((frame) => frame.event === "snapshot"), false); + } finally { + feed.close(); + } +}); + test("revocation closes only the revoked device's feeds", async () => { const { feed, state } = harness(); const revoked = await open(feed, desktop("device-revoked")); diff --git a/main/services/aiden-remote-host-feed.ts b/main/services/aiden-remote-host-feed.ts index 952b83efc..e57a80030 100644 --- a/main/services/aiden-remote-host-feed.ts +++ b/main/services/aiden-remote-host-feed.ts @@ -38,7 +38,14 @@ export interface AidenRemoteHostFeedState { /** Bot-owned chats; their run states reach `bot:read` desktops only. */ botChatIds: ReadonlySet; workspaces: readonly AidenRemoteHostFeedWorkspace[]; + /** Bot rows as a desktop without `bot:handoffs` reads them (revision 35: no `paused`). */ bots: readonly AidenRemoteHostFeedBot[]; + /** + * The same Bots as a `bot:handoffs` desktop reads them (revision 37: a + * paused Bot is `sessionState: "paused"` with `paused: true`). Absent means + * every device reads `bots`. + */ + handoffBots?: readonly AidenRemoteHostFeedBot[]; } export interface AidenRemoteHostFeedSource { @@ -48,6 +55,8 @@ export interface AidenRemoteHostFeedSource { export interface AidenRemoteHostFeedDevice { id: string; capabilities: ReadonlySet; + /** The device is sent revision-37 Bot rows: it holds `bot:handoffs` and the host serves it. */ + botHandoffs?: boolean; } export interface AidenRemoteHostFeedOptions { @@ -76,11 +85,15 @@ export interface AidenRemoteHostFeedEvent { payload: Record; } +/** Which desktops read an entry: every one, or only those with or without `bot:handoffs`. */ +type Audience = "all" | "legacy" | "handoffs"; + interface JournalEntry { sequence: number; frame: string; bytes: number; botOnly: boolean; + audience: Audience; } interface RunStateChange { @@ -115,6 +128,11 @@ export class AidenRemoteHostFeedService { private timer: ReturnType | undefined; private pendingRuns = new Map(); private readonly subscriptions = new Map>(); + /** + * The Bot rows each device last opened the feed with, and the head when it + * switched to them: a cursor older than that belongs to the other rows. + */ + private readonly audiences = new Map(); private closed = false; constructor(private readonly options: AidenRemoteHostFeedOptions) { @@ -172,14 +190,28 @@ export class AidenRemoteHostFeedService { await this.refresh(); admit(); const botVisible = device.capabilities.has("bot:read"); + const audience: Audience = device.botHandoffs === true ? "handoffs" : "legacy"; + // A device that gained or lost `bot:handoffs` since its cursor read other + // Bot rows: replaying from it would keep a paused Bot as it read before. + // The switch advances the head, so every cursor from before it, including + // one kept through a snapshot that never finished arriving, gets a snapshot. + let record = this.audiences.get(device.id); + if (record === undefined) { + record = { audience, since: 0 }; + } else if (record.audience !== audience) { + this.appendMarker(); + record = { audience, since: this.head }; + } + this.audiences.set(device.id, record); let position = this.resumePosition(cursor); + if (position !== undefined && position < record.since) position = undefined; /** Remaining frames of a snapshot being delivered one frame per pull. */ let snapshot: string[] = []; const pull = (): CursorSsePull => { if (snapshot.length > 0) return { frames: [snapshot.shift()!] }; if (position === undefined || position < this.floor) { position = this.head; - snapshot = this.snapshotFrames(botVisible); + snapshot = this.snapshotFrames(botVisible, audience); return { frames: [snapshot.shift()!] }; } const frames: string[] = []; @@ -190,7 +222,9 @@ export class AidenRemoteHostFeedService { ) { const entry = this.journal[index]!; position = entry.sequence; + if (entry.frame === "") continue; if (entry.botOnly && !botVisible) continue; + if (entry.audience !== "all" && entry.audience !== audience) continue; frames.push(entry.frame); } return { frames }; @@ -218,6 +252,7 @@ export class AidenRemoteHostFeedService { /** Close every feed a revoked device holds. */ revokeDevice(deviceId: string): void { + this.audiences.delete(deviceId); const owned = this.subscriptions.get(deviceId); if (!owned) return; this.subscriptions.delete(deviceId); @@ -265,7 +300,7 @@ export class AidenRemoteHostFeedService { if (previous) { this.diff("chat", previous.summaries, next.summaries, false); this.diff("workspace", previous.workspaces, next.workspaces, false); - this.diff("bot", previous.bots, next.bots, true); + this.diffBots(previous, next); } const runs = [...this.pendingRuns.values()]; this.pendingRuns.clear(); @@ -288,7 +323,7 @@ export class AidenRemoteHostFeedService { } private diff( - kind: "chat" | "workspace" | "bot", + kind: "chat" | "workspace", previous: readonly { id: string }[], next: readonly { id: string }[], botOnly: boolean, @@ -306,16 +341,54 @@ export class AidenRemoteHostFeedService { } } + /** + * Bot rows differ by audience only while a Bot is paused, so a change both + * audiences read alike is one entry; otherwise each audience whose row + * changed gets its own. + */ + private diffBots(previous: AidenRemoteHostFeedState, next: AidenRemoteHostFeedState): void { + const rows = (bots: readonly AidenRemoteHostFeedBot[]) => + new Map(bots.map((bot) => [bot.id, { bot, json: JSON.stringify(bot) }])); + const before = { legacy: rows(previous.bots), handoffs: rows(previous.handoffBots ?? previous.bots) }; + const after = { legacy: rows(next.bots), handoffs: rows(next.handoffBots ?? next.bots) }; + const ids = new Set([...after.legacy.keys(), ...after.handoffs.keys(), ...before.legacy.keys(), ...before.handoffs.keys()]); + for (const id of ids) { + const changes = (["legacy", "handoffs"] as const).flatMap((audience) => { + const was = before[audience].get(id); + const now = after[audience].get(id); + if (was?.json === now?.json) return []; + return [{ audience, row: now }]; + }); + const [first, second] = changes; + if (!first) continue; + if (second && first.row?.json === second.row?.json) { + this.appendBot(id, first.row?.bot, "all"); + continue; + } + for (const change of changes) this.appendBot(id, change.row?.bot, change.audience); + } + } + + private appendBot(id: string, bot: AidenRemoteHostFeedBot | undefined, audience: Audience): void { + if (bot) this.append("bot.upsert", bot as unknown as Record, true, audience); + else this.append("bot.remove", { id }, true, audience); + } + private append( type: Exclude, payload: Record, botOnly: boolean, + audience: Audience = "all", ): void { this.head += 1; const frame = this.frame(this.head, type, payload); const bytes = Buffer.byteLength(frame); - this.journal.push({ sequence: this.head, frame, bytes, botOnly }); + this.journal.push({ sequence: this.head, frame, bytes, botOnly, audience }); this.journalBytes += bytes; + this.trimJournal(); + } + + private trimJournal(): void { while ( this.journal.length > this.maxEvents || (this.journalBytes > this.maxBytes && this.journal.length > 0) @@ -326,6 +399,13 @@ export class AidenRemoteHostFeedService { } } + /** An entry no device is sent: it only moves the head past every earlier cursor. */ + private appendMarker(): void { + this.head += 1; + this.journal.push({ sequence: this.head, frame: "", bytes: 0, botOnly: false, audience: "all" }); + this.trimJournal(); + } + private truncateJournal(): void { this.journal.length = 0; this.journalBytes = 0; @@ -339,13 +419,14 @@ export class AidenRemoteHostFeedService { * carries the `:` id. A host that fits in one chunk sends * exactly one frame without `partial`. */ - private snapshotFrames(botVisible: boolean): string[] { + private snapshotFrames(botVisible: boolean, audience: Audience): string[] { const state = this.state!; + const bots = audience === "handoffs" ? (state.handoffBots ?? state.bots) : state.bots; const budget = this.options.snapshotChunkBytes ?? AIDEN_REMOTE_HOST_FEED_SNAPSHOT_CHUNK_BYTES; const sections = { summaries: state.summaries, workspaces: state.workspaces, - bots: botVisible ? state.bots : [], + bots: botVisible ? bots : [], } as const; type Section = keyof typeof sections; const empty = (): Record => ({ summaries: [], workspaces: [], bots: [] }); diff --git a/main/services/aiden-remote-protocol.test.ts b/main/services/aiden-remote-protocol.test.ts index 4b3f58b82..747951af1 100644 --- a/main/services/aiden-remote-protocol.test.ts +++ b/main/services/aiden-remote-protocol.test.ts @@ -2761,8 +2761,13 @@ test("hand-off, answer and paused-flag parsers refuse what a closed decoder woul assert.deepEqual(parseAidenRemoteBotHandoff(card), card); const { why: _why, open: _open, ...bare } = card; assert.deepEqual(parseAidenRemoteBotHandoff({ ...bare, kind: "sign_in" }), { ...bare, kind: "sign_in" }); + assert.deepEqual(parseAidenRemoteBotHandoff({ ...card, takesNote: false }), { ...card, takesNote: false }); + assert.deepEqual(parseAidenRemoteBotHandoff(raw.botHandoffs.noNoteHandoff), raw.botHandoffs.noNoteHandoff); for (const invalid of [ { ...card, extra: true }, + { ...card, takesNote: true }, + { ...card, takesNote: "false" }, + { ...card, takesNote: null }, { ...card, kind: "payment" }, { ...card, waitId: "wait id" }, { ...card, action: " " }, diff --git a/main/services/aiden-remote-protocol.ts b/main/services/aiden-remote-protocol.ts index efa809266..40ed8939e 100644 --- a/main/services/aiden-remote-protocol.ts +++ b/main/services/aiden-remote-protocol.ts @@ -84,9 +84,7 @@ export const AIDEN_REMOTE_PROTOCOL_VERSION = 1 as const; * at merge): Chat Summary rows may carry `lastRunFailed: true` when the newest * assistant output is a failed turn, so an unread row can say Failed. * - * Contract revision 37 (provisional; re-claim the next revision after `main`'s - * at merge by changing only `AIDEN_REMOTE_BOT_HANDOFFS_CONTRACT_REVISION`): - * Bot hand-offs and pause on phones (`bot:handoffs`). A device that negotiates + * Contract revision 37: Bot hand-offs and pause on phones (`bot:handoffs`). A device that negotiates * `bot:handoffs` receives the session `handoff` field and frame, the `paused` * session state, Bot `paused` flags and the `bot_paused` error code, and may * answer hand-offs, open them on the Mac and pause or unpause a Bot. Devices @@ -108,7 +106,17 @@ export const AIDEN_REMOTE_WEB_EXTRACT_CONTRACT_REVISION = 38 as const; * without it keep the summary-only card. */ export const AIDEN_REMOTE_SCHEDULE_CARDS_CONTRACT_REVISION = 39 as const; -export const AIDEN_REMOTE_CONTRACT_REVISION = AIDEN_REMOTE_SCHEDULE_CARDS_CONTRACT_REVISION; +/** + * Contract revision 40 (provisional; re-claim the next revision after `main`'s + * at merge by changing only `AIDEN_REMOTE_BOT_HANDOFF_NOTE_CONTRACT_REVISION`): + * a device that also negotiates `bot:handoff-notes` is told when a hand-off + * card takes no note (one a password-or-code rule opened): the card carries + * `takesNote: false`, so the device hides its note box instead of learning + * from a `400` that the note was refused. Revision 37 shipped in v0.61.0, + * whose closed hand-off decoder rejects the key, so only holders get it. + */ +export const AIDEN_REMOTE_BOT_HANDOFF_NOTE_CONTRACT_REVISION = 40 as const; +export const AIDEN_REMOTE_CONTRACT_REVISION = AIDEN_REMOTE_BOT_HANDOFF_NOTE_CONTRACT_REVISION; export const AIDEN_REMOTE_BASE_PATH = "/api/aiden/v1" as const; export const AIDEN_REMOTE_MAX_SSE_FRAME_BYTES = 1_048_576; export const AIDEN_REMOTE_MAX_JSON_RESPONSE_BYTES = 1_048_576; @@ -283,9 +291,11 @@ export const AIDEN_REMOTE_BOT_ACTIVITY_CAPABILITIES = ["bot:activity"] as const; * `handoff` field and frames, the `paused` session state and Bot `paused` * flags, and the `bot_paused` error code; it may call the hand-off answer, * open-on-Mac and pause routes. It is an opt-in like `bot:cards`: the routes - * still need `bot:read` and `bot:write`. + * still need `bot:read` and `bot:write`. Revision 40: `bot:handoff-notes`, + * offered beside it, adds `takesNote: false` to a card that takes no note; it + * changes nothing without `bot:handoffs`. */ -export const AIDEN_REMOTE_BOT_HANDOFF_CAPABILITIES = ["bot:handoffs"] as const; +export const AIDEN_REMOTE_BOT_HANDOFF_CAPABILITIES = ["bot:handoffs", "bot:handoff-notes"] as const; export type AidenRemoteBotHandoffCapability = (typeof AIDEN_REMOTE_BOT_HANDOFF_CAPABILITIES)[number]; @@ -1410,6 +1420,12 @@ export interface AidenRemoteBotHandoff { why?: string; /** An https page that helps. */ open?: { url: string }; + /** + * Contract revision 40: present, as `false`, only on a card whose answer + * may carry no note (a password-or-code rule opened it). Absent means a + * note is welcome. + */ + takesNote?: false; } /** `POST /bots/{botId}/handoffs/{waitId}/answer`. A non-blank `note` is at most 4,000 characters. */ @@ -5785,7 +5801,8 @@ export function parseAidenRemoteBotHandoffWaitId(value: unknown, label = "Bot ha /** Revision 37: the hand-off a Bot waits on. `null` in a session means nothing is waiting. */ export function parseAidenRemoteBotHandoff(value: unknown): AidenRemoteBotHandoff { if (!isRecord(value)) throw new Error("Bot hand-off must be an object."); - assertExactKeys(value, ["waitId", "kind", "action", "why", "open"], "Bot hand-off"); + assertExactKeys(value, ["waitId", "kind", "action", "why", "open", "takesNote"], "Bot hand-off"); + if (hasOwn(value, "takesNote") && value.takesNote !== false) throw new Error("Bot hand-off takesNote must be false when present."); let open: { url: string } | undefined; if (hasOwn(value, "open")) { if (!isRecord(value.open)) throw new Error("Bot hand-off open must be an object."); @@ -5802,6 +5819,7 @@ export function parseAidenRemoteBotHandoff(value: unknown): AidenRemoteBotHandof action, ...(hasOwn(value, "why") ? { why: boundedText(value.why, "Bot hand-off why", BOT_HANDOFF_WHY_MAX_CHARS) } : {}), ...(open ? { open } : {}), + ...(hasOwn(value, "takesNote") ? { takesNote: false as const } : {}), }; } @@ -6051,6 +6069,8 @@ export interface AidenRemoteBotActivityFixture { */ export interface AidenRemoteBotHandoffsFixture { capability: AidenRemoteBotHandoffCapability; + /** Revision 40: a card a password-or-code rule opened, which takes no note. */ + noNoteHandoff: AidenRemoteBotHandoff; /** A running session waiting on a hand-off, as a holder sees it. */ session: AidenRemoteBotSession; /** The hand-off opening, settling and the person's answer as their message. */ @@ -6146,12 +6166,14 @@ function parseBotHandoffsFixture(value: unknown): AidenRemoteBotHandoffsFixture assertExactKeys( value, [ - "capability", "session", "events", "answer", "openOnMac", "pause", "unpause", + "capability", "noNoteHandoff", "session", "events", "answer", "openOnMac", "pause", "unpause", "pausedSession", "pausedSummary", "pausedDetail", "pausedError", "legacy", ], "Bot hand-offs fixture", ); const capability = enumMember(value.capability, AIDEN_REMOTE_BOT_HANDOFF_CAPABILITIES, "Bot hand-offs fixture capability"); + const noNoteHandoff = parseAidenRemoteBotHandoff(value.noNoteHandoff); + if (noNoteHandoff.takesNote !== false) throw new Error("Bot hand-offs fixture noNoteHandoff must take no note."); if (!Array.isArray(value.events)) throw new Error("Bot hand-offs fixture events must be an array."); const answer = requestResponseRecord(value.answer, "Bot hand-off answer"); const openOnMac = requestResponseRecord(value.openOnMac, "Bot hand-off open-on-Mac"); @@ -6206,6 +6228,7 @@ function parseBotHandoffsFixture(value: unknown): AidenRemoteBotHandoffsFixture } return { capability, + noNoteHandoff, session, events: value.events.map(parseAidenRemoteBotSessionEvent), answer: { diff --git a/main/services/aiden-remote-router.test.ts b/main/services/aiden-remote-router.test.ts index 4fbe4a5cc..c27ac35bf 100644 --- a/main/services/aiden-remote-router.test.ts +++ b/main/services/aiden-remote-router.test.ts @@ -258,6 +258,7 @@ async function fixture(options: { capability !== "bot:activity" && capability !== "bot:cards" && capability !== "bot:handoffs" && + capability !== "bot:handoff-notes" && capability !== "schedule:cards", ) ) { @@ -5756,9 +5757,10 @@ function recordingHandoffSessions(seen: string[], supportsHandoffs: boolean) { const HANDOFF_WAIT = "4f9c2d1e-7a3b-4c5d-9e8f-0a1b2c3d4e5f"; -test("bot:handoffs is offered and negotiable only while the hand-off and pause routes are wired, with no new feature token", async () => { +test("bot:handoffs and bot:handoff-notes are offered and negotiable only while the hand-off and pause routes are wired, with no new feature token", async () => { const headers = { authorization: `Bearer ${"a".repeat(43)}`, "aiden-protocol-version": "1" }; - const negotiate = { method: "POST", headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify({ accepts: ["bot:handoffs"] }) }; + const handoffCapabilities = ["bot:handoffs", "bot:handoff-notes"]; + const negotiate = { method: "POST", headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify({ accepts: handoffCapabilities }) }; const features: Record = {}; for (const wired of [true, false]) { const app = await fixture({ @@ -5769,13 +5771,15 @@ test("bot:handoffs is offered and negotiable only while the hand-off and pause r }); try { const server = await (await fetch(`${app.base}/server`, { headers })).json(); - assert.equal(server.serverCapabilities.includes("bot:handoffs"), wired); - assert.equal(server.capabilities.includes("bot:handoffs"), false, "offered, not yet granted"); + for (const capability of handoffCapabilities) { + assert.equal(server.serverCapabilities.includes(capability), wired, capability); + assert.equal(server.capabilities.includes(capability), false, `${capability} offered, not yet granted`); + } features[String(wired)] = server.features; const response = await fetch(`${app.base}/device/capabilities`, negotiate); assert.equal(response.status, wired ? 200 : 404); const body = await response.json(); - if (wired) assert.ok(body.capabilities.includes("bot:handoffs")); + if (wired) for (const capability of handoffCapabilities) assert.ok(body.capabilities.includes(capability), capability); else assert.equal(body.error?.code, "not_found"); } finally { await app.close(); @@ -5792,7 +5796,7 @@ test("bot:handoffs is offered and negotiable only while the hand-off and pause r }); try { const server = await (await fetch(`${legacy.base}/server`, { headers })).json(); - assert.equal((server.serverCapabilities ?? []).includes("bot:handoffs"), false); + for (const capability of handoffCapabilities) assert.equal((server.serverCapabilities ?? []).includes(capability), false, capability); } finally { await legacy.close(); } @@ -6152,3 +6156,36 @@ test("a fully wired host tells every device type at most the server feature budg assert.deepEqual([...announced].sort(), [...unrestricted].sort(), type); } }); + +test("a paired desktop's host feed carries paused Bot rows only when it holds bot:handoffs on a host that serves them", async () => { + const cases: Array<[grants: AidenRemoteCapability[], wired: boolean, expected: boolean]> = [ + [[...HOST_GRANTS, "bot:read", "bot:handoffs"], true, true], + [[...HOST_GRANTS, "bot:read"], true, false], + [[...HOST_GRANTS, "bot:read", "bot:handoffs"], false, false], + ]; + for (const [grants, wired, expected] of cases) { + const audiences: Array = []; + const mac = await fixture({ + hostFeed: { + open: async (device, _cursor, response) => { + audiences.push(device.botHandoffs); + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end(); + }, + }, + deviceType: "mac", + acceptsProgressCapabilities: true, + acceptsBotCapabilities: true, + capabilities: grants, + botSessions: recordingHandoffSessions([], wired), + }); + try { + const feed = await fetch(`${mac.base}/host/events`, { headers: HOST_HEADERS }); + assert.equal(feed.status, 200); + await feed.text(); + assert.deepEqual(audiences, [expected], JSON.stringify({ grants, wired })); + } finally { + await mac.close(); + } + } +}); diff --git a/main/services/aiden-remote-router.ts b/main/services/aiden-remote-router.ts index 395957e21..ef18978a6 100644 --- a/main/services/aiden-remote-router.ts +++ b/main/services/aiden-remote-router.ts @@ -1706,6 +1706,14 @@ function holdsBotHandoffs( return device.capabilities.has("bot:handoffs") && botHandoffsWired(dependencies); } +/** Whether this device is also told when a hand-off card takes no note (revision 40). */ +function holdsBotHandoffNotes( + device: AidenRemoteRouterAuthenticatedDevice, + dependencies: AidenRemoteRouterDependencies, +): boolean { + return device.capabilities.has("bot:handoff-notes") && holdsBotHandoffs(device, dependencies); +} + /** A revision-37 route: not_found where unwired, capability_denied without `bot:handoffs`. */ function requireBotHandoffs( device: AidenRemoteRouterAuthenticatedDevice, @@ -2448,6 +2456,7 @@ export function createAidenRemoteRequestHandler( cards: device.capabilities.has("bot:cards"), activity: device.capabilities.has("bot:activity") && botActivityWired(dependencies), handoffs: holdsBotHandoffs(device, dependencies), + handoffNotes: holdsBotHandoffNotes(device, dependencies), }), ); return; @@ -2469,6 +2478,7 @@ export function createAidenRemoteRequestHandler( cards: device.capabilities.has("bot:cards"), activity: device.capabilities.has("bot:activity") && botActivityWired(dependencies), handoffs: holdsBotHandoffs(device, dependencies), + handoffNotes: holdsBotHandoffNotes(device, dependencies), }, ); return; @@ -4058,7 +4068,12 @@ export function createAidenRemoteRequestHandler( const device = await authenticate(request, dependencies.devices, "host:events"); deviceIdSuffix = device.id.slice(-8); if (!dependencies.hostFeed) throw hostRunsUnavailable(); - await dependencies.hostFeed.open(device, cursor, response, admitDevice(device.id)); + await dependencies.hostFeed.open( + { id: device.id, capabilities: device.capabilities, botHandoffs: holdsBotHandoffs(device, dependencies) }, + cursor, + response, + admitDevice(device.id), + ); return; } const chatMessagesMatch = /^\/chats\/([A-Za-z0-9._:-]{1,128})\/messages$/u.exec(path); diff --git a/main/services/aiden-remote-service-main.ts b/main/services/aiden-remote-service-main.ts index e3f3200b4..6aeb2c252 100644 --- a/main/services/aiden-remote-service-main.ts +++ b/main/services/aiden-remote-service-main.ts @@ -1003,10 +1003,14 @@ async function createRuntime(): Promise { ...(repository ? { repository } : {}), }; }), - // Paired desktops get the revision-35 Bot rows: no `paused` state or flag. + // A desktop without `bot:handoffs` gets the revision-35 rows (a paused + // Bot reads as unavailable); a holder gets `paused` (revision 37). bots: (botList?.bots ?? []).map((bot) => projectBotSummaryForAudience(bot, LEGACY_AUDIENCE), ) as unknown as AidenRemoteHostFeedBot[], + handoffBots: (botList?.bots ?? []).map((bot) => + projectBotSummaryForAudience(bot, { handoffs: true }), + ) as unknown as AidenRemoteHostFeedBot[], }; }, }, diff --git a/main/services/bot-rules/store.test.ts b/main/services/bot-rules/store.test.ts index 7bafc9535..b33ad8200 100644 --- a/main/services/bot-rules/store.test.ts +++ b/main/services/bot-rules/store.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { after, test } from "node:test"; @@ -167,6 +167,35 @@ test("a file that doesn't parse exactly fails closed: calls can't read it, and t assert.equal(byId(await p.store.rules(BOT), id)?.text, "book a flight"); }); +test("replacing a damaged rules file keeps each damaged version beside it, and deleting the Bot removes them", async () => { + const p = profile(); + const directory = path.dirname(p.file); + const first = '{"version":1,"revision":4,"custom":[{"text":"never book flights over 500 dollars"'; + handWrite(p.file, first); + const saved = await p.store.update(BOT, 0, { type: "set_behavior", ruleId: "default.purchase", behavior: "hand_off" }); + assert.equal(saved.ok, true); + assert.equal(readFileSync(path.join(directory, "rules.json.unreadable"), "utf8"), first); + + // A refused change on a damaged file keeps nothing; the next saved one keeps the new damage without touching the first copy. + // The copy is the file's exact bytes, even ones that aren't valid UTF-8. + const second = Buffer.from([0x7b, 0x22, 0x63, 0x61, 0x66, 0xe9, 0x22, 0x3a]); + writeFileSync(p.file, second); + const refused = await p.store.update(BOT, 0, { type: "set_behavior", ruleId: "locked.money", behavior: "ask" }); + assert.equal(refused.ok, false); + assert.equal(existsSync(path.join(directory, "rules.json.unreadable.2")), false); + assert.equal((await p.store.update(BOT, 0, { type: "add_custom", text: "order food", behavior: "ask" })).ok, true); + assert.equal(readFileSync(path.join(directory, "rules.json.unreadable"), "utf8"), first); + assert.deepEqual(readFileSync(path.join(directory, "rules.json.unreadable.2")), second); + + // A readable file is replaced without a copy. + assert.equal((await p.store.update(BOT, 1, { type: "add_custom", text: "book a flight", behavior: "ask" })).ok, true); + assert.equal(existsSync(path.join(directory, "rules.json.unreadable.3")), false); + + writeFileSync(path.join(directory, "notes.txt"), "kept"); + await p.store.forgetBot(BOT); + assert.deepEqual(readdirSync(directory), ["notes.txt"]); +}); + test("an unreadable rules file throws instead of reading as the defaults", async () => { const p = profile(); mkdirSync(p.file, { recursive: true }); diff --git a/main/services/bot-rules/store.ts b/main/services/bot-rules/store.ts index d9069777a..9b3157fb0 100644 --- a/main/services/bot-rules/store.ts +++ b/main/services/bot-rules/store.ts @@ -6,14 +6,17 @@ // that does not parse exactly fails closed: the Bot's calls can't read their // rules (so a call a rule would check doesn't run), the editor shows the // defaults marked `unreadable`, and its next change starts again from them, -// replacing the file. Every edit names the revision it was made against, so -// two editors never overwrite each other silently. +// replacing the file. That change first keeps the damaged bytes beside it as +// `rules.json.unreadable` (then `.2`, `.3`, ...), so rules the person wrote can +// still be recovered by hand. Every edit names the revision it was made +// against, so two editors never overwrite each other silently. // // The Bot's directory is shared with its session database, which deleting the // Bot removes. This store never brings that directory back for a deleted Bot: // it writes only for a Bot the Bot store still knows, `forgetBot` refuses // every later write in this process (checked again right before publishing), -// and it removes the file and the directory it may have recreated. +// and it removes the file, its kept copies and the directory it may have +// recreated. import { promises as fs } from "node:fs"; import path from "node:path"; @@ -40,6 +43,8 @@ import { createKeyedSerial, botSessionDirectory } from "../bot-routine-notes.js" import { contentStems } from "./words.js"; export const BOT_RULES_FILE = "rules.json"; +/** A damaged rules file is kept under this name (then `.2`, `.3`, ...) before a change replaces it. */ +export const BOT_RULES_UNREADABLE_COPY = `${BOT_RULES_FILE}.unreadable`; const FILE_VERSION = 1; export interface BotRuleStore { @@ -143,6 +148,8 @@ function parseRulesFile(value: unknown): RulesFile | undefined { interface StoredRules { file: RulesFile; unreadable: boolean; + /** The damaged file's exact bytes, kept aside before a change replaces them. */ + damaged?: Buffer; } function viewOf(botId: string, stored: StoredRules): BotRulesView { @@ -209,6 +216,8 @@ function applyChange(file: RulesFile, change: BotRuleChange): Applied { } } +const UNREADABLE_COPY_NAME = /^rules\.json\.unreadable(?:\.[1-9]\d*)?$/u; + function errorCode(error: unknown): string | undefined { return error !== null && typeof error === "object" ? (error as NodeJS.ErrnoException).code : undefined; } @@ -220,9 +229,9 @@ export function createBotRuleStore(options: BotRuleStoreOptions): BotRuleStore { const fileOf = (botId: string) => path.join(directory(botId), BOT_RULES_FILE); async function read(botId: string): Promise { - let text: string; + let bytes: Buffer; try { - text = await fs.readFile(fileOf(botId), "utf8"); + bytes = await fs.readFile(fileOf(botId)); } catch (error) { // No file (or no directory yet): nothing changed from the defaults. if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return { file: EMPTY, unreadable: false }; @@ -230,11 +239,24 @@ export function createBotRuleStore(options: BotRuleStoreOptions): BotRuleStore { } let parsed: RulesFile | undefined; try { - parsed = parseRulesFile(JSON.parse(text)); + parsed = parseRulesFile(JSON.parse(bytes.toString("utf8"))); } catch { parsed = undefined; } - return parsed === undefined ? { file: EMPTY, unreadable: true } : { file: parsed, unreadable: false }; + return parsed === undefined ? { file: EMPTY, unreadable: true, damaged: bytes } : { file: parsed, unreadable: false }; + } + + /** Keeps a damaged file's bytes under the first free copy name, never over an earlier copy. */ + async function keepDamagedCopy(botId: string, bytes: Buffer): Promise { + for (let n = 1; ; n += 1) { + const name = n === 1 ? BOT_RULES_UNREADABLE_COPY : `${BOT_RULES_UNREADABLE_COPY}.${n}`; + try { + await fs.writeFile(path.join(directory(botId), name), bytes, { mode: 0o600, flag: "wx" }); + return; + } catch (error) { + if (errorCode(error) !== "EEXIST") throw error; + } + } } async function ensureDirectory(botId: string): Promise { @@ -272,6 +294,7 @@ export function createBotRuleStore(options: BotRuleStoreOptions): BotRuleStore { const applied = applyChange(current.file, change); if (!applied.ok) return { ok: false, reason: applied.reason, view: viewOf(botId, current) }; await ensureDirectory(botId); + if (current.damaged !== undefined) await keepDamagedCopy(botId, current.damaged); await writeJsonAtomic(fileOf(botId), applied.file, { mode: 0o600, space: 2, @@ -286,6 +309,11 @@ export function createBotRuleStore(options: BotRuleStoreOptions): BotRuleStore { forgotten.add(botId); return serial(botId, async () => { await fs.rm(fileOf(botId), { force: true }); + const names = await fs.readdir(directory(botId)).catch((error: unknown) => { + if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") return [] as string[]; + throw error; + }); + await Promise.all(names.filter((name) => UNREADABLE_COPY_NAME.test(name)).map((name) => fs.rm(path.join(directory(botId), name), { force: true }))); // A write that raced the Bot's deletion may have recreated an empty directory. await fs.rmdir(directory(botId)).catch((error: unknown) => { if (errorCode(error) !== "ENOENT" && errorCode(error) !== "ENOTEMPTY" && errorCode(error) !== "EEXIST") throw error; diff --git a/main/services/peer-host-registry.test.ts b/main/services/peer-host-registry.test.ts index 82394cd20..d0434ed19 100644 --- a/main/services/peer-host-registry.test.ts +++ b/main/services/peer-host-registry.test.ts @@ -601,6 +601,11 @@ test("a Mac asks for a host's Bot hand-offs in a call of its own, after the rest assert.deepEqual((await negotiate(undefined)).bodies, [{ accepts: ["bot:activity"] }]); // With nothing else to ask for, the hand-off call still goes alone. assert.deepEqual((await negotiate(["chat:read", "bot:read", "bot:write", "bot:handoffs"])).bodies, [{ accepts: ["bot:handoffs"] }]); + + // Revision 40: a host that also lists bot:handoff-notes is asked for it the same way; a v0.61.0 host above never was. + const notes = await negotiate([...lists, "bot:handoff-notes"]); + assert.deepEqual(notes.bodies, [{ accepts: ["bot:activity"] }, { accepts: ["bot:handoffs"] }, { accepts: ["bot:handoff-notes"] }]); + assert.ok(notes.grants.includes("bot:handoff-notes")); }); test("a lost opt-in answer is asked again on the next reconnect; a refusal is not", async () => { diff --git a/main/services/peer-host-registry.ts b/main/services/peer-host-registry.ts index 0dadede0c..5eece8279 100644 --- a/main/services/peer-host-registry.ts +++ b/main/services/peer-host-registry.ts @@ -115,6 +115,8 @@ export interface PeerClient { * It has no feature token of its own, so it is asked for only when the host * lists it in `serverCapabilities`, and in a call of its own after the rest * (`"own_call"`): a host that does not know a name refuses the whole request. + * `bot:handoff-notes` (revision 40), asked for the same way, also tells this + * Mac when a hand-off card takes no note. */ const NEGOTIATED_GRANTS: ReadonlyArray = [ ["tasks:read", AIDEN_REMOTE_CHAT_TASKS_FEATURE], @@ -126,6 +128,7 @@ const NEGOTIATED_GRANTS: ReadonlyArray {}); const admission = new ChatTurnAdmission(); const deletion = new ChatDeletionGate(); - const active = new Map; finish(): void; release(): void }>(); + const active = new Map; finish(): void; release(): void }>(); const reserved = new Set(); const idleWaiters = new Map void>>(); const wakeIdle = (id: string) => { for (const wake of idleWaiters.get(id) ?? []) wake(); idleWaiters.delete(id); }; @@ -85,7 +86,14 @@ export function createDaemonChats(agentDir: string) { } return true; }, - async cancelChat(id: string) { admission.releaseChat(id); const entry = active.get(id); entry?.abort.abort(); await entry?.session?.abort(); }, + /** Stops the chat's turn; the first stop's origin is what its timeline records, as on the desktop. */ + async cancelChat(id: string, origin: GenerationCancellationOrigin = "chat_deletion") { + admission.releaseChat(id); const entry = active.get(id); + if (entry && !entry.abort.signal.aborted) entry.cancellationOrigin ??= origin; + entry?.abort.abort(); await entry?.session?.abort(); + }, + /** A Bot the person paused: its reply ends as their Stop, and the chat is kept. */ + async stopChatWork(id: string) { await llmClient.cancelChat(id, "user_stop"); return llmClient.waitForChatIdle(id); }, approve(id: string, decision: "allow" | "deny", ownerId: string) { const pending = approvals.get(id); if (!pending || pending.ownerId !== ownerId) return false; approvals.delete(id); pending.resolve(decision === "allow"); return true; @@ -93,7 +101,7 @@ export function createDaemonChats(agentDir: string) { cancel(streamId: string, ownerId: string) { const pair = [...active.entries()].find(([, entry]) => entry.streamId === streamId && entry.ownerId === ownerId); if (!pair) return false; - void llmClient.cancelChat(pair[0]); return true; + void llmClient.cancelChat(pair[0], "user_stop"); return true; }, async start(streamId: string, params: ChatStartParams, owner: ChatGenerationOwner, options: { usageSource: UsageRequestSource; turnId?: string; botAudienceId?: string; onTurnAccepted?(): void }) { if (owner.isDestroyed() || !admission.owns(params.chatId, options.turnId ?? streamId, owner.documentId)) return false; @@ -264,7 +272,7 @@ export function createDaemonChats(agentDir: string) { send("chat:done", { streamId, content, chat: updated, timeline: finalTimeline }); return true; } catch (error) { const cancelled = entry.abort.signal.aborted; - const finalTimeline = timeline.finish(cancelled ? "cancelled" : "failed"); + const finalTimeline = cancelled ? timeline.finish("cancelled", entry.cancellationOrigin) : timeline.finish("failed"); if (!persisted && (content || produced.length)) { await chatStore.appendMessage(params.chatId, { role: "assistant", content, timeline: finalTimeline }).catch(() => { process.stderr.write("aiden: Could not persist the partial response.\n"); @@ -282,6 +290,6 @@ export function createDaemonChats(agentDir: string) { } return { chatStore, llmClient, activity, admission, deletion, sessionPath, setBots(runtime: CliBotGenerationRuntime) { bots = runtime; }, - async stop() { const ids = new Set([...reserved, ...active.keys()]); admission.releaseAll(); await Promise.allSettled([...ids].map((id) => llmClient.cancelChat!(id))); await Promise.all([...ids].map((id) => llmClient.waitForChatIdle(id))); }, + async stop() { const ids = new Set([...reserved, ...active.keys()]); admission.releaseAll(); await Promise.allSettled([...ids].map((id) => llmClient.cancelChat(id, "application_shutdown"))); await Promise.all([...ids].map((id) => llmClient.waitForChatIdle(id))); }, }; } diff --git a/packages/cli/tests/parity.test.mjs b/packages/cli/tests/parity.test.mjs index 355b65b69..148890cc4 100644 --- a/packages/cli/tests/parity.test.mjs +++ b/packages/cli/tests/parity.test.mjs @@ -916,6 +916,10 @@ test("a paused Bot stops its running turn, refuses new ones and runs again once assert.equal(await runtime.isPaused(bot.id), true); const stopped = await running.idle(); assert.equal(stopped.filter((event) => event.channel === "chat:done").length, 0, "the held reply never finishes"); + // As on the Mac, the stopped reply is the person's Stop, not a deletion or a failure. + const stoppedTimeline = stopped.find((event) => event.channel === "chat:error")?.value.timeline; + assert.equal(stoppedTimeline?.status, "cancelled", JSON.stringify(stopped)); + assert.equal(stoppedTimeline?.cancellationOrigin, "user_stop"); held.end(); assert.equal(statSync(join(dir, "bot-service", "bot-pauses.json")).mode & 0o777, 0o600); assert.deepEqual(await runtime.command(["pause", bot.id]), paused, "pausing again keeps the first time"); diff --git a/protocol/aiden-remote/v1/fixtures/contract.json b/protocol/aiden-remote/v1/fixtures/contract.json index 2a9c940c9..79955da13 100644 --- a/protocol/aiden-remote/v1/fixtures/contract.json +++ b/protocol/aiden-remote/v1/fixtures/contract.json @@ -1,5 +1,5 @@ { - "contractRevision": 39, + "contractRevision": 40, "peerRouteAdvertisement": {"feature":"peer-routes-v1","routes":[{"endpoint":"https://fixture-peer.example.ts.net/api/aiden/v1","serverSpkiSha256":"sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="}]}, "providerCreation": {"label":"Fixture private model","baseUrl":"https://models.example.test/v1","kind":"openai","deployment":"hosted","needsKey":false,"models":[{"id":"fixture-vision","vision":true,"reasoning":false,"toolCall":true}],"confirmedForeground":true}, "providerCreationReceipt": {"id":"custom:remote-fixture","label":"Fixture private model","models":["fixture-vision"]}, @@ -1539,7 +1539,7 @@ "instanceId": "instance_fixture_01", "displayName": "Fixture Aiden", "platform": "mac", - "contractRevision": 39, + "contractRevision": 40, "pairingRequests": true }, "hostFeedEvents": [ @@ -2402,6 +2402,12 @@ }, "botHandoffs": { "capability": "bot:handoffs", + "noNoteHandoff": { + "waitId": "8a1d3c5e-2b4f-4a6c-8d0e-1f2a3b4c5d6e", + "kind": "action", + "action": "Enter the code your bank texted you", + "takesNote": false + }, "session": { "botId": "bot_fixture_01", "epoch": "epoch_fixture_36", diff --git a/protocol/aiden-remote/v1/openapi.json b/protocol/aiden-remote/v1/openapi.json index 3e10ce262..52fb57b22 100644 --- a/protocol/aiden-remote/v1/openapi.json +++ b/protocol/aiden-remote/v1/openapi.json @@ -7257,6 +7257,7 @@ "bot:cards", "bot:activity", "bot:handoffs", + "bot:handoff-notes", "schedule:cards" ] }, @@ -7296,6 +7297,7 @@ "bot:cards", "bot:activity", "bot:handoffs", + "bot:handoff-notes", "schedule:cards" ] }, @@ -7438,6 +7440,7 @@ "bot:cards", "bot:activity", "bot:handoffs", + "bot:handoff-notes", "schedule:cards" ] }, @@ -7486,6 +7489,7 @@ "bot:cards", "bot:activity", "bot:handoffs", + "bot:handoff-notes", "schedule:cards" ] }, @@ -16928,6 +16932,10 @@ } }, "additionalProperties": false + }, + "takesNote": { + "description": "Contract revision 40, only for devices that also hold bot:handoff-notes: present, and false, only on a card whose answer may carry no note (one the Bot's password-or-code rule opened). Clients hide the note box for it; a note with its answer is 400 invalid_request whether or not the device was told.", + "const": false } }, "additionalProperties": false diff --git a/renderer/components/bots/handoff-card.tsx b/renderer/components/bots/handoff-card.tsx index f9824df25..b0ddfed8c 100644 --- a/renderer/components/bots/handoff-card.tsx +++ b/renderer/components/bots/handoff-card.tsx @@ -34,7 +34,10 @@ function linkHost(url: string): string | undefined { } type ShownHandoff = Pick & - Pick, "origin" | "target">; + Pick, "origin" | "target"> & { + /** A paired Mac's card that takes no note (Remote contract revision 40). */ + takesNote?: boolean; + }; function onComputer(handoff: Pick): boolean { return handoff.kind === "sign_in" && handoff.target === "computer"; diff --git a/renderer/components/remote-bots-section.tsx b/renderer/components/remote-bots-section.tsx index d9b6f398c..c43ce9b53 100644 --- a/renderer/components/remote-bots-section.tsx +++ b/renderer/components/remote-bots-section.tsx @@ -3,6 +3,7 @@ import type { RemoteBotGroup } from "../lib/hosts/new-chat-targets"; import { matchesSearch } from "../lib/search-fold"; import { RemoteHostMarker } from "./sidebar-remote"; import { cn } from "../lib/ui-utils"; +import { BOT_INTERRUPTED_ROW_LABEL, BOT_PAUSED_ROW_LABEL } from "../main/bots/bot-session-state"; import { Text } from "./ui"; /** The paired Macs' Bots that match the sidebar's search; a Mac with none is left out. */ @@ -49,6 +50,13 @@ export function RemoteBotsSection({ ) : null}
    {bots.map((bot) => { + const status = bot.working + ? { label: "Working…", spoken: ", working" } + : bot.status === "paused" + ? { label: BOT_PAUSED_ROW_LABEL, spoken: ", paused" } + : bot.status === "interrupted" + ? { label: BOT_INTERRUPTED_ROW_LABEL, spoken: ", interrupted" } + : null; const busy = opening === `${host.id}/${bot.id}`; const current = selected === `${host.id}/${bot.id}`; return ( @@ -58,7 +66,7 @@ export function RemoteBotsSection({ disabled={Boolean(host.disabledReason) || opening !== null} aria-busy={busy || undefined} aria-current={current ? "page" : undefined} - aria-label={`${bot.name}, on ${host.label}${bot.working ? ", working" : ""}`} + aria-label={`${bot.name}, on ${host.label}${status?.spoken ?? ""}`} className={cn( "flex w-full min-w-0 items-center gap-2.5 rounded-control px-2 py-1.5 text-left outline-none transition-[background-color] duration-(--motion-duration) ease-standard hover:bg-list-hover focus-visible:bg-list-selection focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus-ring disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent motion-reduce:transition-none", current && "bg-list-selection hover:bg-list-selection", @@ -73,7 +81,7 @@ export function RemoteBotsSection({ - {bot.working ? "Working…" : (bot.purpose ?? `A Bot on ${host.label}.`)} + {status?.label ?? bot.purpose ?? `A Bot on ${host.label}.`} diff --git a/renderer/lib/hosts/new-chat-targets.test.ts b/renderer/lib/hosts/new-chat-targets.test.ts index f30192a21..748caa5d8 100644 --- a/renderer/lib/hosts/new-chat-targets.test.ts +++ b/renderer/lib/hosts/new-chat-targets.test.ts @@ -137,6 +137,11 @@ test("Bots are grouped by host, without hosts that have none", () => { bots: [ { id: "bot-1", name: "Reviewer", purpose: "Reviews pull requests" }, { id: "bot-4" }, + { id: "bot-6", name: "Paused one", sessionState: "paused", paused: true }, + { id: "bot-7", name: "Stopped one", sessionState: "interrupted" }, + { id: "bot-8", name: "Busy one", sessionState: "running" }, + // A host that sends this Mac the revision-35 rows says only `unavailable`. + { id: "bot-9", name: "Older host's paused one", sessionState: "unavailable" }, ], }), ], @@ -152,6 +157,10 @@ test("Bots are grouped by host, without hosts that have none", () => { [ { id: "bot-1", name: "Reviewer", purpose: "Reviews pull requests" }, { id: "bot-4", name: "Untitled Bot" }, + { id: "bot-6", name: "Paused one", status: "paused" }, + { id: "bot-7", name: "Stopped one", status: "interrupted" }, + { id: "bot-8", name: "Busy one", working: true }, + { id: "bot-9", name: "Older host's paused one" }, ], ], ["host-d", [{ id: "bot-5", name: "Scribe" }]], diff --git a/renderer/lib/hosts/new-chat-targets.ts b/renderer/lib/hosts/new-chat-targets.ts index 4e5c0fa0a..658918132 100644 --- a/renderer/lib/hosts/new-chat-targets.ts +++ b/renderer/lib/hosts/new-chat-targets.ts @@ -118,6 +118,12 @@ export interface RemoteBotChoice { purpose?: string; /** The host says a turn is running. */ working?: true; + /** + * The person paused the Bot (only a host that sends this Mac the + * `bot:handoffs` rows says so), or a turn stopped part-way and waits to be + * resumed. + */ + status?: "paused" | "interrupted"; } export interface RemoteBotGroup { @@ -146,6 +152,9 @@ export function remoteBotGroups( name: text(row, "name") ?? "Untitled Bot", ...(purpose ? { purpose } : {}), ...(row.sessionState === "running" ? { working: true as const } : {}), + ...(row.sessionState === "paused" || row.sessionState === "interrupted" + ? { status: row.sessionState as "paused" | "interrupted" } + : {}), }; }); if (bots.length === 0) continue; diff --git a/renderer/main/bots/bot-rules.test.tsx b/renderer/main/bots/bot-rules.test.tsx index 58ec289e1..1406a33df 100644 --- a/renderer/main/bots/bot-rules.test.tsx +++ b/renderer/main/bots/bot-rules.test.tsx @@ -168,7 +168,7 @@ test("rules Aiden can't read say the Bot won't take the steps they check, until const list = await mount(); assert.match( (await screen.findByRole("alert")).textContent ?? "", - /Planner’s saved rules couldn’t be read, so Planner won’t take any step its rules check\. Change a rule below to start again from these defaults\./u, + /Planner’s saved rules couldn’t be read, so Planner won’t take any step its rules check\. Change a rule below to start again from these defaults\. Aiden keeps a copy of the damaged file, rules\.json\.unreadable, in Planner’s folder\./u, ); assert.ok(within(row(list, "Permanently delete something")).getByText("Ask first"), "the editor shows what a change starts from"); await assert.rejects(store.rules(bot.id), "the Bot's calls still can't read them"); diff --git a/renderer/main/bots/bot-rules.tsx b/renderer/main/bots/bot-rules.tsx index 0e69c7fd4..a45255952 100644 --- a/renderer/main/bots/bot-rules.tsx +++ b/renderer/main/bots/bot-rules.tsx @@ -87,10 +87,10 @@ export function BotRules({ bot }: { bot: RuleBot }) { ) : rules.data?.unreadable ? ( - // Main fails closed on a rules file it can't parse; the next change replaces it. + // Main fails closed on a rules file it can't parse; the next change keeps a copy and replaces it. - {bot.name}’s saved rules couldn’t be read, so {bot.name} won’t take any step its rules check. Change a rule below to start again from these defaults. + {bot.name}’s saved rules couldn’t be read, so {bot.name} won’t take any step its rules check. Change a rule below to start again from these defaults. Aiden keeps a copy of the damaged file, rules.json.unreadable, in {bot.name}’s folder. ) : null} diff --git a/renderer/main/remote-bot-chat-view.test.tsx b/renderer/main/remote-bot-chat-view.test.tsx index 1e589c53b..9a57c41c3 100644 --- a/renderer/main/remote-bot-chat-view.test.tsx +++ b/renderer/main/remote-bot-chat-view.test.tsx @@ -405,6 +405,24 @@ test("a sign-in is finished on the Bot's own Mac: the card offers Finish there a assert.equal(handoffAnswers().length, 0); }); +test("a password-or-code card from another Mac takes no note: no note box, and Done answers it bare", async () => { + const { calls, handoffAnswers } = await mountHandoff({ + ...RUNNING_WITH_HANDOFF, + handoff: { ...BANK_HANDOFF, takesNote: false }, + }); + await screen.findByRole("group", { name: "Planner needs you" }); + assert.equal(screen.queryByPlaceholderText("Add a note and send to mark it done"), null); + assert.equal(screen.queryByText(/Planner reads any note you send/u), null); + assert.ok( + screen.getByText(/This card takes no note, so a password or code never reaches Planner\./u, { selector: "[role=status]" }), + "the composer says why it can't send", + ); + fireEvent.click(screen.getByRole("button", { name: "Done" })); + await waitFor(() => assert.equal(handoffAnswers().length, 1)); + assert.deepEqual(handoffAnswers()[0]!.body, { outcome: "done" }); + assert.equal(calls.some((call) => call.channel === "remote:peerCall" && (call.args[1] as PeerOperation).operation === "botSend"), false); +}); + test("a paused Bot on another Mac says so, blocks the composer, and Resume lifts the pause there", async () => { const { peerCalls } = await mountHandoff(SESSION); await screen.findByText("Two meetings and a dentist."); diff --git a/renderer/main/remote-bot-chat-view.tsx b/renderer/main/remote-bot-chat-view.tsx index 7cb9e886c..47f1fadb1 100644 --- a/renderer/main/remote-bot-chat-view.tsx +++ b/renderer/main/remote-bot-chat-view.tsx @@ -8,7 +8,7 @@ import { skipToken, useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { Ellipsis, Pause, Play, Square } from "lucide-react"; import { AskUserQuestionComposer } from "../components/ask-user-question-composer"; -import { HandoffCard } from "../components/bots/handoff-card"; +import { HandoffCard, handoffNoNoteComposerText } from "../components/bots/handoff-card"; import { Composer } from "../components/composer"; import { MessageAttachmentPreviewProvider } from "../components/message-attachments"; import { @@ -30,7 +30,7 @@ import { browserApi, peerHostsApi } from "../lib/ipc"; import type { Attachment } from "../lib/types"; import type { AskUserQuestionResponseV1 } from "../shared/ask-user-question"; import { DEFAULT_BOT_AVATAR, isBotAvatar, resolveBotChatActivity, type BotAvatar } from "../shared/bots"; -import { BOT_HANDOFF_NOTE_MAX_CHARS, type BotHandoffOutcome } from "../shared/bot-handoffs"; +import { BOT_HANDOFF_NOTE_MAX_CHARS, botHandoffTakesNote, type BotHandoffOutcome } from "../shared/bot-handoffs"; import type { BotPendingQuestion } from "../shared/bot-live"; import { mintPeerIdempotencyKey, type PeerHostFeedSnapshot, type PeerHostView, type PeerOperationOutcome } from "../shared/peer-host"; import type { PeerOperation } from "../shared/peer-operation"; @@ -311,6 +311,8 @@ export function RemoteBotChatView({ hostId, botId }: { hostId: string; botId: st const handoff = view.handoff && view.handoff !== settledHandoff ? view.handoff : null; // A sign-in happens in the Bot's own browser on its Mac: typing here can't finish it. const signInElsewhere = handoff?.kind === "sign_in"; + // A password-or-code card is answered on the card alone: whatever is typed here would reach the Bot. + const noteRefused = handoff !== null && !botHandoffTakesNote(handoff); const send = async (text: string, attachments: Attachment[], whenBusy?: "steer" | "followUp"): Promise => { const images = attachments.filter((attachment) => attachment.kind === "image" && attachment.data); @@ -457,6 +459,7 @@ export function RemoteBotChatView({ hostId, botId }: { hostId: string; botId: st * shows the error's message. */ const answerHandoffWithNote = async (pending: AidenRemoteBotHandoff, text: string, attachments: Attachment[]) => { + if (!botHandoffTakesNote(pending)) throw new Error(handoffNoNoteComposerText(name)); if (attachments.length > 0) { throw new Error( attachments.some((attachment) => attachment.kind === "image") @@ -592,19 +595,21 @@ export function RemoteBotChatView({ hostId, botId }: { hostId: string; botId: st ) : ( 0} chatId={`remote-bot:${hostId}:${botId}`} - placeholder={handoff ? "Add a note and send to mark it done" : `Ask ${name}`} + placeholder={handoff && !noteRefused ? "Add a note and send to mark it done" : `Ask ${name}`} onSend={(text, attachments) => (handoff ? answerHandoffWithNote(handoff, text, attachments) : send(text, attachments))} {...(steerAndQueue && !handoff ? { diff --git a/renderer/main/remote-new-chat-view.test.tsx b/renderer/main/remote-new-chat-view.test.tsx index 23539b9e4..987a733d1 100644 --- a/renderer/main/remote-new-chat-view.test.tsx +++ b/renderer/main/remote-new-chat-view.test.tsx @@ -271,6 +271,32 @@ test("Bots on paired Macs are grouped by Mac, and a Mac that cannot open them sa assert.equal(renderToStaticMarkup(), ""); }); +test("a paired Mac's paused or interrupted Bot says so in place of its purpose", () => { + const view = dom( + renderToStaticMarkup( + , + ), + ); + assert.equal(view.button(/^Reviewer, on Studio, paused$/u)?.textContent?.includes("Paused"), true); + assert.equal(view.button(/^Scribe, on Studio, interrupted$/u)?.textContent?.includes("Interrupted — tap to resume"), true); + assert.equal(view.button(/^Planner, on Studio, working$/u)?.textContent?.includes("Working…"), true); + assert.doesNotMatch(view.text, /Reviews pull requests|Takes notes|Plans the week/u); + assert.equal(view.button(/^Reviewer, on Studio, paused$/u)?.hasAttribute("disabled"), false, "a paused Bot's chat still opens"); +}); + test("search in the Bots sidebar narrows paired Macs' Bots too, and hides a Mac with no match", () => { const groups = [ { host: studio, bots: [{ id: "bot-1", name: "Reviewer", purpose: "Reviews pull requests" }, { id: "bot-2", name: "Scribe" }] }, diff --git a/renderer/shared/bot-handoffs.ts b/renderer/shared/bot-handoffs.ts index b66bea6b6..3774030ab 100644 --- a/renderer/shared/bot-handoffs.ts +++ b/renderer/shared/bot-handoffs.ts @@ -72,9 +72,12 @@ const CREDENTIAL_RULE_IDS: ReadonlySet = new Set( * words to the Bot: it reaches the model with the answer. A card a * password-or-code rule opened takes none, so a password or code typed as a * note can never reach the Bot; it is answered on the card itself. (A sign-in - * may still carry a note with Skip; no composer sends one with it.) + * may still carry a note with Skip; no composer sends one with it.) A paired + * Mac's card names no rule and says `takesNote: false` instead (Remote + * contract revision 40). */ -export function botHandoffTakesNote(handoff: Pick): boolean { +export function botHandoffTakesNote(handoff: Pick & { takesNote?: boolean }): boolean { + if (handoff.takesNote === false) return false; return handoff.rule === undefined || !CREDENTIAL_RULE_IDS.has(handoff.rule.id); }