From 8a0a2aa9f625d1fb2cfb5c85a7be8bdf9cbc18c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 15:52:58 +0000 Subject: [PATCH 1/9] feat(bot-rules): keep a damaged rules file's bytes before a change replaces it The owner chose to keep a copy (question 6): the first change after an unreadable rules.json writes its bytes to rules.json.unreadable (then .2, .3, never over an earlier copy). Deleting the Bot removes the copies. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FTLKrZsPKZM2hnxHVxEnak --- docs/bot-rules.md | 7 ++++-- main/services/bot-rules/store.test.ts | 30 +++++++++++++++++++++- main/services/bot-rules/store.ts | 36 ++++++++++++++++++++++++--- renderer/main/bots/bot-rules.test.tsx | 2 +- renderer/main/bots/bot-rules.tsx | 4 +-- 5 files changed, 69 insertions(+), 10 deletions(-) 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/main/services/bot-rules/store.test.ts b/main/services/bot-rules/store.test.ts index 7bafc9535..c86d4b9dd 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,34 @@ 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. + const second = "{also broken"; + handWrite(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.equal(readFileSync(path.join(directory, "rules.json.unreadable.2"), "utf8"), 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..ef235f1a5 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 bytes, kept aside before a change replaces them. */ + damaged?: string; } 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; } @@ -234,7 +243,20 @@ export function createBotRuleStore(options: BotRuleStoreOptions): BotRuleStore { } catch { parsed = undefined; } - return parsed === undefined ? { file: EMPTY, unreadable: true } : { file: parsed, unreadable: false }; + return parsed === undefined ? { file: EMPTY, unreadable: true, damaged: text } : { 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: string): 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, { encoding: "utf8", 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/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} From bc370a03bf28933e77b60ab1d77fde429916499c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 15:57:14 +0000 Subject: [PATCH 2/9] fix(cli): a paused Bot's stopped reply records the person's Stop, as on the Mac The owner chose to match the Mac (question 4). The CLI daemon now keeps the first stop's origin on the cancelled timeline: user_stop for Stop and Pause, chat_deletion for a deleted chat and application_shutdown when it stops. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FTLKrZsPKZM2hnxHVxEnak --- .memory/bot-pause.md | 2 +- docs/aiden-remote-api-v1.md | 2 +- packages/cli/src/bots.ts | 5 ++--- packages/cli/src/daemon-chats.ts | 18 +++++++++++++----- packages/cli/tests/parity.test.mjs | 4 ++++ 5 files changed, 21 insertions(+), 10 deletions(-) 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/docs/aiden-remote-api-v1.md b/docs/aiden-remote-api-v1.md index 165dd69ef..0df9064dc 100644 --- a/docs/aiden-remote-api-v1.md +++ b/docs/aiden-remote-api-v1.md @@ -778,7 +778,7 @@ 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`.** diff --git a/packages/cli/src/bots.ts b/packages/cli/src/bots.ts index 0468a25d1..e743c9887 100644 --- a/packages/cli/src/bots.ts +++ b/packages/cli/src/bots.ts @@ -116,9 +116,8 @@ export async function createCliBots(agentDir: string, daemon: ReturnType {}); 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); }; @@ -67,7 +68,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; @@ -75,7 +83,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; @@ -246,7 +254,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"); @@ -264,6 +272,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 1928c57c4..c9dea28c3 100644 --- a/packages/cli/tests/parity.test.mjs +++ b/packages/cli/tests/parity.test.mjs @@ -890,6 +890,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"); From fc0bdaef4fea48e9f566f9d465f1fed79ff2b3bc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:08:13 +0000 Subject: [PATCH 3/9] feat(remote): a paired desktop's Bot list shows Paused and Interrupted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host feed now sends Bot rows per audience: a desktop holding bot:handoffs reads a paused Bot as paused, others keep the revision-35 rows. A change both audiences read alike stays one event. The paired host's Bot rows show "Paused" or "Interrupted — tap to resume" in place of the Bot's purpose, as local Bot rows do. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FTLKrZsPKZM2hnxHVxEnak --- .memory/bot-handoffs-clients.md | 2 +- .memory/remote-rev37-bot-handoffs.md | 2 +- docs/aiden-remote-api-v1.md | 4 +- main/services/aiden-remote-host-feed.test.ts | 62 ++++++++++++++++++++ main/services/aiden-remote-host-feed.ts | 62 ++++++++++++++++++-- main/services/aiden-remote-router.test.ts | 33 +++++++++++ main/services/aiden-remote-router.ts | 7 ++- main/services/aiden-remote-service-main.ts | 6 +- renderer/components/remote-bots-section.tsx | 12 +++- renderer/lib/hosts/new-chat-targets.test.ts | 9 +++ renderer/lib/hosts/new-chat-targets.ts | 9 +++ renderer/main/remote-new-chat-view.test.tsx | 26 ++++++++ 12 files changed, 220 insertions(+), 14 deletions(-) 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/remote-rev37-bot-handoffs.md b/.memory/remote-rev37-bot-handoffs.md index 4e2857995..1e5db503d 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`. diff --git a/docs/aiden-remote-api-v1.md b/docs/aiden-remote-api-v1.md index 0df9064dc..3230f4f80 100644 --- a/docs/aiden-remote-api-v1.md +++ b/docs/aiden-remote-api-v1.md @@ -356,7 +356,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. @@ -795,7 +795,7 @@ A Bot can hand a step back to the person ("Your turn: Enter the 2FA code your ba - 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. diff --git a/main/services/aiden-remote-host-feed.test.ts b/main/services/aiden-remote-host-feed.test.ts index 001ddfca4..27a098471 100644 --- a/main/services/aiden-remote-host-feed.test.ts +++ b/main/services/aiden-remote-host-feed.test.ts @@ -344,6 +344,68 @@ 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("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..2336b8cd3 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 { @@ -172,6 +185,7 @@ export class AidenRemoteHostFeedService { await this.refresh(); admit(); const botVisible = device.capabilities.has("bot:read"); + const audience: Audience = device.botHandoffs === true ? "handoffs" : "legacy"; let position = this.resumePosition(cursor); /** Remaining frames of a snapshot being delivered one frame per pull. */ let snapshot: string[] = []; @@ -179,7 +193,7 @@ export class AidenRemoteHostFeedService { 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[] = []; @@ -191,6 +205,7 @@ export class AidenRemoteHostFeedService { const entry = this.journal[index]!; position = entry.sequence; if (entry.botOnly && !botVisible) continue; + if (entry.audience !== "all" && entry.audience !== audience) continue; frames.push(entry.frame); } return { frames }; @@ -265,7 +280,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 +303,7 @@ export class AidenRemoteHostFeedService { } private diff( - kind: "chat" | "workspace" | "bot", + kind: "chat" | "workspace", previous: readonly { id: string }[], next: readonly { id: string }[], botOnly: boolean, @@ -306,15 +321,49 @@ 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; while ( this.journal.length > this.maxEvents || @@ -339,13 +388,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-router.test.ts b/main/services/aiden-remote-router.test.ts index 01b6aefab..1383e3c4c 100644 --- a/main/services/aiden-remote-router.test.ts +++ b/main/services/aiden-remote-router.test.ts @@ -6069,3 +6069,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 016ba1fc2..ad0ab9636 100644 --- a/main/services/aiden-remote-router.ts +++ b/main/services/aiden-remote-router.ts @@ -4039,7 +4039,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/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/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" }] }, From dcfc01933d560d58d74466beb7e4163c7258bfe4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:17:33 +0000 Subject: [PATCH 4/9] feat(remote): rev 38 hand-off cards say when they take no note A card the Bot's password-or-code rule opened now carries takesNote: false. The desktop's paired-Mac chat, iOS and Android hold the composer for it ("Enter it yourself, then tap Done.") and answer it without a note, instead of learning from a 400 that a note was refused. Revision 37 shipped in no release, so the field needs no opt-in. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FTLKrZsPKZM2hnxHVxEnak --- .memory/remote-rev37-bot-handoffs.md | 5 +++ .../bots/AidenBotSessionController.kt | 16 ++++++++-- .../features/bots/AidenBotSessionScreen.kt | 1 + .../AidenOnTheGo/models/AidenBotSession.kt | 25 ++++++++++++--- android/app/src/main/res/values/strings.xml | 1 + .../AidenBotHandoffsContractTest.kt | 9 ++++++ .../bots/AidenBotSessionControllerTest.kt | 19 ++++++++++++ docs/aiden-on-the-go-remote-access.md | 2 +- docs/aiden-remote-api-v1.md | 10 +++--- .../Bots/AidenBotSessionChatView.swift | 13 +++++--- .../Features/Bots/AidenBotSessionModel.swift | 13 ++++++-- ios/AidenOnTheGo/Models/AidenBotSession.swift | 26 +++++++++++++--- .../AidenBotContractTests.swift | 10 ++++++ .../AidenBotSessionTests.swift | 23 ++++++++++++++ .../AidenRemotePhase0Tests.swift | 2 +- .../aiden-remote-bot-handoffs.test.ts | 18 +++++++++++ main/services/aiden-remote-bot-handoffs.ts | 11 +++++-- main/services/aiden-remote-protocol.test.ts | 5 +++ main/services/aiden-remote-protocol.ts | 31 +++++++++++++++---- .../aiden-remote/v1/fixtures/contract.json | 10 ++++-- protocol/aiden-remote/v1/openapi.json | 4 +++ renderer/components/bots/handoff-card.tsx | 5 ++- renderer/main/remote-bot-chat-view.test.tsx | 18 +++++++++++ renderer/main/remote-bot-chat-view.tsx | 19 +++++++----- renderer/shared/bot-handoffs.ts | 7 +++-- 25 files changed, 259 insertions(+), 44 deletions(-) diff --git a/.memory/remote-rev37-bot-handoffs.md b/.memory/remote-rev37-bot-handoffs.md index 1e5db503d..b842bbc4d 100644 --- a/.memory/remote-rev37-bot-handoffs.md +++ b/.memory/remote-rev37-bot-handoffs.md @@ -66,3 +66,8 @@ 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 38: `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. +- No opt-in: revision 37 shipped in no release. `AIDEN_REMOTE_BOT_HANDOFF_NOTE_CONTRACT_REVISION = 38` is the contract revision; re-claim at merge if `main` moved. +- 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..15c1b8b8d 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 @@ -309,6 +309,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 38: 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 +379,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 38: 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 +391,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 +401,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 +1516,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 38) 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/models/AidenBotSession.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenBotSession.kt index 206e72bf0..492d2094e 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 38: 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 38: 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/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 875cb71dc..7651c0bc8 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..811efebbf 100644 --- a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotHandoffsContractTest.kt +++ b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotHandoffsContractTest.kt @@ -202,6 +202,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 38: 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) 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 a604786b3..611e6d4ea 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. ## Paired-Mac voice input diff --git a/docs/aiden-remote-api-v1.md b/docs/aiden-remote-api-v1.md index 3230f4f80..def86a30b 100644 --- a/docs/aiden-remote-api-v1.md +++ b/docs/aiden-remote-api-v1.md @@ -767,9 +767,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 38 (provisional; re-claimed at merge after whatever `main` holds then, in `AIDEN_REMOTE_BOT_HANDOFF_NOTE_CONTRACT_REVISION`) adds `takesNote` to the card, below. Revision 37 shipped in no release, so `takesNote` needs no opt-in of its own: every holder may receive it. 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. @@ -783,12 +783,12 @@ A Bot can hand a step back to the person ("Your turn: Enter the 2FA code your ba **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 38, `takesNote` is present, and `false`, only on a card whose answer may carry no note: one the Bot's password-or-code rule opened. 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 38 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. @@ -797,6 +797,6 @@ A Bot can hand a step back to the person ("Your turn: Enter the 2FA code your ba **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, 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 38) 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/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift index 5701e84a0..092dec8a0 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionChatView.swift @@ -876,14 +876,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 38: 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" } @@ -1010,8 +1014,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 fd4806c0a..d8cb789bf 100644 --- a/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift +++ b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift @@ -221,6 +221,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 38: 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." @@ -2010,8 +2012,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 38) holds the composer instead (`composerHeldForNoNote`). + var composerAnswersHandoff: Bool { handoff?.kind == .action && handoff?.takesNote != false } + + /// Revision 38: 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 @@ -2025,6 +2032,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/Models/AidenBotSession.swift b/ios/AidenOnTheGo/Models/AidenBotSession.swift index f4a84d55a..7e46764ab 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 38: 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 38: 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 38). + 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/AidenOnTheGoTests/AidenBotContractTests.swift b/ios/AidenOnTheGoTests/AidenBotContractTests.swift index ce9cf52cf..fba050210 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 38: 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/AidenRemotePhase0Tests.swift b/ios/AidenOnTheGoTests/AidenRemotePhase0Tests.swift index 74907f078..3253ad002 100644 --- a/ios/AidenOnTheGoTests/AidenRemotePhase0Tests.swift +++ b/ios/AidenOnTheGoTests/AidenRemotePhase0Tests.swift @@ -148,7 +148,7 @@ final class AidenRemotePhase0Tests: XCTestCase { from: data ) - XCTAssertEqual(fixture.contractRevision, 37) + XCTAssertEqual(fixture.contractRevision, 38) // Revision 19 run-control losers learn the winning decision; phones keep // their mobile-only grants, so the fixture never offers host capabilities. let runControlError = try XCTUnwrap(fixture.runControlError?.error) 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..2ffed71ef 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 38), + * 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-protocol.test.ts b/main/services/aiden-remote-protocol.test.ts index df513ddae..5bc19792f 100644 --- a/main/services/aiden-remote-protocol.test.ts +++ b/main/services/aiden-remote-protocol.test.ts @@ -2648,8 +2648,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 af0bb902a..4ea475f0a 100644 --- a/main/services/aiden-remote-protocol.ts +++ b/main/services/aiden-remote-protocol.ts @@ -80,17 +80,23 @@ 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 * without it see a paused Bot as unavailable, a pending hand-off as an * assistant notice, and `operation_stale` for writes to a paused Bot. + * + * Contract revision 38 (provisional; re-claim the next revision after `main`'s + * at merge by changing only `AIDEN_REMOTE_BOT_HANDOFF_NOTE_CONTRACT_REVISION`): + * a hand-off card that takes no note (one a password-or-code rule opened) + * carries `takesNote: false`, so a holder hides its note box instead of + * learning from a `400` that the note was refused. Revision 37 shipped in no + * release, so the field needs no opt-in of its own. */ export const AIDEN_REMOTE_BOT_HANDOFFS_CONTRACT_REVISION = 37 as const; -export const AIDEN_REMOTE_CONTRACT_REVISION = AIDEN_REMOTE_BOT_HANDOFFS_CONTRACT_REVISION; +export const AIDEN_REMOTE_BOT_HANDOFF_NOTE_CONTRACT_REVISION = 38 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; @@ -1376,6 +1382,12 @@ export interface AidenRemoteBotHandoff { why?: string; /** An https page that helps. */ open?: { url: string }; + /** + * Contract revision 38: 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. */ @@ -5749,7 +5761,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."); @@ -5766,6 +5779,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 } : {}), }; } @@ -6015,6 +6029,8 @@ export interface AidenRemoteBotActivityFixture { */ export interface AidenRemoteBotHandoffsFixture { capability: AidenRemoteBotHandoffCapability; + /** Revision 38: 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. */ @@ -6045,12 +6061,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"); @@ -6105,6 +6123,7 @@ function parseBotHandoffsFixture(value: unknown): AidenRemoteBotHandoffsFixture } return { capability, + noNoteHandoff, session, events: value.events.map(parseAidenRemoteBotSessionEvent), answer: { diff --git a/protocol/aiden-remote/v1/fixtures/contract.json b/protocol/aiden-remote/v1/fixtures/contract.json index de9a92107..dc9b6c454 100644 --- a/protocol/aiden-remote/v1/fixtures/contract.json +++ b/protocol/aiden-remote/v1/fixtures/contract.json @@ -1,5 +1,5 @@ { - "contractRevision": 37, + "contractRevision": 38, "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": 37, + "contractRevision": 38, "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 e421f2553..40f206ff6 100644 --- a/protocol/aiden-remote/v1/openapi.json +++ b/protocol/aiden-remote/v1/openapi.json @@ -16521,6 +16521,10 @@ } }, "additionalProperties": false + }, + "takesNote": { + "description": "Contract revision 38: 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.", + "const": false } }, "additionalProperties": false diff --git a/renderer/components/bots/handoff-card.tsx b/renderer/components/bots/handoff-card.tsx index f9824df25..5944e4b51 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 38). */ + takesNote?: boolean; + }; function onComputer(handoff: Pick): boolean { return handoff.kind === "sign_in" && handoff.target === "computer"; diff --git a/renderer/main/remote-bot-chat-view.test.tsx b/renderer/main/remote-bot-chat-view.test.tsx index 0d35174f8..d3b449356 100644 --- a/renderer/main/remote-bot-chat-view.test.tsx +++ b/renderer/main/remote-bot-chat-view.test.tsx @@ -400,6 +400,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/shared/bot-handoffs.ts b/renderer/shared/bot-handoffs.ts index b66bea6b6..0bc09767a 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 38). */ -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); } From 7b25453ec43d30041e9bf4c10f7310d763df2b63 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:21:30 +0000 Subject: [PATCH 5/9] docs(bot-computers): the screen-sharing password on Lume's command line is an accepted limit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FTLKrZsPKZM2hnxHVxEnak --- .memory/bot-computers.md | 2 +- docs/bot-computers.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) 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/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 From 1cc875928c0c5ce768b5451a88a88de3f6be9501 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 20:57:50 +0000 Subject: [PATCH 6/9] Keep a damaged Bot rules file byte for byte The copy kept aside for a rules file that does not parse was read as UTF-8 text, so bytes that are not valid UTF-8 were replaced on the way to the .unreadable copy. Read and keep the raw bytes instead. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FTLKrZsPKZM2hnxHVxEnak --- main/services/bot-rules/store.test.ts | 7 ++++--- main/services/bot-rules/store.ts | 16 ++++++++-------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/main/services/bot-rules/store.test.ts b/main/services/bot-rules/store.test.ts index c86d4b9dd..b33ad8200 100644 --- a/main/services/bot-rules/store.test.ts +++ b/main/services/bot-rules/store.test.ts @@ -177,14 +177,15 @@ test("replacing a damaged rules file keeps each damaged version beside it, and d 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. - const second = "{also broken"; - handWrite(p.file, second); + // 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.equal(readFileSync(path.join(directory, "rules.json.unreadable.2"), "utf8"), second); + 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); diff --git a/main/services/bot-rules/store.ts b/main/services/bot-rules/store.ts index ef235f1a5..9b3157fb0 100644 --- a/main/services/bot-rules/store.ts +++ b/main/services/bot-rules/store.ts @@ -148,8 +148,8 @@ function parseRulesFile(value: unknown): RulesFile | undefined { interface StoredRules { file: RulesFile; unreadable: boolean; - /** The damaged file's bytes, kept aside before a change replaces them. */ - damaged?: string; + /** The damaged file's exact bytes, kept aside before a change replaces them. */ + damaged?: Buffer; } function viewOf(botId: string, stored: StoredRules): BotRulesView { @@ -229,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 }; @@ -239,19 +239,19 @@ 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, damaged: text } : { 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: string): Promise { + 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, { encoding: "utf8", mode: 0o600, flag: "wx" }); + await fs.writeFile(path.join(directory(botId), name), bytes, { mode: 0o600, flag: "wx" }); return; } catch (error) { if (errorCode(error) !== "EEXIST") throw error; From 339c5c7331b23a0fed24ff49988815fd729bcf4c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 20:57:50 +0000 Subject: [PATCH 7/9] Send a fresh host-feed snapshot when a desktop's Bot audience changes A desktop that reconnected holding bot:handoffs (or no longer holding it) resumed from its old cursor and kept Bot rows shaped for the other audience. The feed now remembers each device's audience and answers a cursor from a different audience with a snapshot. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FTLKrZsPKZM2hnxHVxEnak --- main/services/aiden-remote-host-feed.test.ts | 27 ++++++++++++++++++++ main/services/aiden-remote-host-feed.ts | 9 ++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/main/services/aiden-remote-host-feed.test.ts b/main/services/aiden-remote-host-feed.test.ts index 27a098471..939785532 100644 --- a/main/services/aiden-remote-host-feed.test.ts +++ b/main/services/aiden-remote-host-feed.test.ts @@ -406,6 +406,33 @@ test("a paused Bot reads as paused only on desktops that hold bot:handoffs", asy } }); +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 }]); + + // Back without it, the cursor from the holder's snapshot doesn't replay either. + const back = await open(feed, legacyDevice, frames.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 2336b8cd3..602d66842 100644 --- a/main/services/aiden-remote-host-feed.ts +++ b/main/services/aiden-remote-host-feed.ts @@ -128,6 +128,8 @@ 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; its cursor resumes only those. */ + private readonly audiences = new Map(); private closed = false; constructor(private readonly options: AidenRemoteHostFeedOptions) { @@ -186,7 +188,11 @@ export class AidenRemoteHostFeedService { admit(); const botVisible = device.capabilities.has("bot:read"); const audience: Audience = device.botHandoffs === true ? "handoffs" : "legacy"; - let position = this.resumePosition(cursor); + // 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. + const previous = this.audiences.get(device.id); + this.audiences.set(device.id, audience); + let position = previous === undefined || previous === audience ? this.resumePosition(cursor) : undefined; /** Remaining frames of a snapshot being delivered one frame per pull. */ let snapshot: string[] = []; const pull = (): CursorSsePull => { @@ -233,6 +239,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); From 34f08da79925b512fddeed3b5056adc23222674f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 20:57:50 +0000 Subject: [PATCH 8/9] Send takesNote only to devices that negotiate bot:handoff-notes Revision 37 shipped in v0.61.0, whose closed hand-off decoder refuses keys it does not know, so a takesNote card would break those clients. A new opt-in, bot:handoff-notes, offered with bot:handoffs, gates the field: every other holder gets the revision 37 card, and the host still refuses a note on a password-or-code card. The desktop peer, iOS and Android ask for it only when the Mac offers it, and reopen an open Bot chat's feed once it is granted. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FTLKrZsPKZM2hnxHVxEnak --- .memory/remote-rev37-bot-handoffs.md | 3 +- .../bots/AidenBotSessionController.kt | 9 ++- .../features/remote/AidenRemoteCoordinator.kt | 23 ++++-- .../AidenOnTheGo/models/AidenInstallation.kt | 4 + .../protocol/AidenRemoteProtocol.kt | 10 ++- .../AidenBotHandoffsContractTest.kt | 31 +++++++ docs/aiden-remote-api-v1.md | 7 +- .../Features/Bots/AidenBotSessionModel.swift | 5 +- .../Remote/AidenRemoteCoordinator.swift | 10 ++- .../Models/AidenInstallation.swift | 1 + .../Networking/AidenRemoteClient.swift | 7 ++ .../Networking/AidenRemoteContract.swift | 7 ++ .../AidenRemoteClientTests.swift | 80 +++++++++++++++++++ .../services/aiden-remote-bot-session.test.ts | 39 ++++++++- main/services/aiden-remote-bot-session.ts | 24 +++++- main/services/aiden-remote-protocol.ts | 15 ++-- main/services/aiden-remote-router.test.ts | 18 +++-- main/services/aiden-remote-router.ts | 10 +++ main/services/peer-host-registry.test.ts | 5 ++ main/services/peer-host-registry.ts | 3 + protocol/aiden-remote/v1/openapi.json | 14 ++-- 21 files changed, 282 insertions(+), 43 deletions(-) diff --git a/.memory/remote-rev37-bot-handoffs.md b/.memory/remote-rev37-bot-handoffs.md index b842bbc4d..8065efe4b 100644 --- a/.memory/remote-rev37-bot-handoffs.md +++ b/.memory/remote-rev37-bot-handoffs.md @@ -69,5 +69,6 @@ Plan: `docs/plans/bot-handoffs-rules-computers-plan.md`, package P4 (host side). ## Revision 38: `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. -- No opt-in: revision 37 shipped in no release. `AIDEN_REMOTE_BOT_HANDOFF_NOTE_CONTRACT_REVISION = 38` is the contract revision; re-claim at merge if `main` moved. +- 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 (`negotiateBotHandoffsIfNeeded`) 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 = 38` is the contract revision; re-claim at merge if `main` moved (#588 also claims 38). - 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 15c1b8b8d..a0bf5cf27 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). */ 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 f9ea6aa8c..938231385 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,19 +91,25 @@ 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 38: + * `bot:handoff-notes` joins that call only when the Mac offers it too, so a Mac on revision 37 + * 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 = - server.offersBotHandoffs && - !server.capabilities.contains(AidenRemoteCapability.BOT_HANDOFFS) && - !installation.hasNegotiatedAccess(AidenRemoteCapability.BOT_HANDOFFS) + aidenBotHandoffGrantsToRequest(server, installation).isNotEmpty() /** What asking a Mac for `bot:handoffs` came to: its fresh `/server`, and the grants to keep once it lists them all. */ internal data class AidenBotHandoffsNegotiation(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 38, `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]). [AidenBotHandoffsNegotiation.granted] is set only once the Mac's own * `serverCapabilities` lists every grant it returned. A 401 or a revoked credential is rethrown. @@ -114,9 +120,10 @@ internal suspend fun aidenRequestBotHandoffs( installation: AidenInstallation, stillCurrent: () -> Boolean = { true } ): AidenBotHandoffsNegotiation? { - if (!stillCurrent() || !aidenShouldRequestBotHandoffs(server, installation)) return null + val missing = aidenBotHandoffGrantsToRequest(server, installation) + if (!stillCurrent() || missing.isEmpty()) return null return try { - val complete = client.updateDeviceCapabilities(listOf(AidenRemoteCapability.BOT_HANDOFFS)) + 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/AidenInstallation.kt b/android/app/src/main/java/sbtbiswas/AidenOnTheGo/models/AidenInstallation.kt index a0f377e8f..61f480262 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 38). 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 1a5837d06..c969b0b60 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 38: a hand-off card that takes no note says so (`takesNote`). It + * grants no authority. A Mac on revision 37 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") val V1_KNOWN = listOf( SERVER_READ, CHAT_READ, CHAT_WRITE, APPROVAL_RESPOND, @@ -573,8 +579,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) /** Every grant `POST /device/capabilities` may add. */ val NEGOTIABLE = PROGRESS + PHONE_RUNS + PHONE_SIMULATORS + PHONE_BOT_CARDS + PHONE_BOT_ACTIVITY + diff --git a/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotHandoffsContractTest.kt b/android/app/src/test/java/sbtbiswas/AidenOnTheGo/AidenBotHandoffsContractTest.kt index 811efebbf..fdb571c81 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.* @@ -304,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/docs/aiden-remote-api-v1.md b/docs/aiden-remote-api-v1.md index def86a30b..98d91474f 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 38). 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 on revision 37 refuses the name, so devices ask for it only when the host lists it. | | `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. | | `questions:respond` | Resolve `ask_user_question` prompts owned by this device/stream and receive `question_required` events. Granted only after explicit client negotiation at pairing or post-pairing. | @@ -769,7 +770,7 @@ A host announces `chat-visual-viewer-v1` when it ships the viewer bundle (`npm r ## Bot hand-offs and pause, revision 37 (`bot:handoffs`) -The host keeps the revision in one constant (`AIDEN_REMOTE_BOT_HANDOFFS_CONTRACT_REVISION`). Revision 38 (provisional; re-claimed at merge after whatever `main` holds then, in `AIDEN_REMOTE_BOT_HANDOFF_NOTE_CONTRACT_REVISION`) adds `takesNote` to the card, below. Revision 37 shipped in no release, so `takesNote` needs no opt-in of its own: every holder may receive it. +The host keeps the revision in one constant (`AIDEN_REMOTE_BOT_HANDOFFS_CONTRACT_REVISION`). Revision 38 (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. @@ -783,7 +784,7 @@ A Bot can hand a step back to the person ("Your turn: Enter the 2FA code your ba **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 }, takesNote? }` or `null`. Since revision 38, `takesNote` is present, and `false`, only on a card whose answer may carry no note: one the Bot's password-or-code rule opened. 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. +- The snapshot always carries `handoff`: `{ waitId, kind: "action" | "sign_in", action, why?, open?: { url }, takesNote? }` or `null`. Since revision 38, `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. @@ -797,6 +798,6 @@ A Bot can hand a step back to the person ("Your turn: Enter the 2FA code your ba **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). For a card with `takesNote: false` (revision 38) 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. +**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 38) 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/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift b/ios/AidenOnTheGo/Features/Bots/AidenBotSessionModel.swift index d8cb789bf..1830a92f1 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` diff --git a/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift b/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift index 906375d00..3c63db06b 100644 --- a/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift +++ b/ios/AidenOnTheGo/Features/Remote/AidenRemoteCoordinator.swift @@ -888,6 +888,8 @@ final class AidenRemoteCoordinator { /// after the progress batch. 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. + /// Revision 38: `bot:handoff-notes` joins that call only when the Mac + /// offers it, so a Mac on revision 37 still grants `bot:handoffs`. private func negotiateBotHandoffsIfNeeded( client: AidenRemoteClient, installationId: String, @@ -895,12 +897,14 @@ final class AidenRemoteCoordinator { ) async { guard isCurrentContext(installationId: installationId, generation: generation), let server = self.server, server.instanceId == installationId, server.offersBotHandoffs, - let installation = installationStore.installations.first(where: { $0.id == installationId }), - !installation.hasNegotiatedAccess(to: .botHandoffs) else { + let installation = installationStore.installations.first(where: { $0.id == installationId }) else { return } + let offered: [AidenRemoteCapability] = server.offersBotHandoffNotes ? [.botHandoffs, .botHandoffNotes] : [.botHandoffs] + let missing = offered.filter { !installation.hasNegotiatedAccess(to: $0) } + guard !missing.isEmpty else { return } do { - let negotiatedCapabilities = try await client.updateDeviceCapabilities(accepts: [.botHandoffs]) + 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/AidenInstallation.swift b/ios/AidenOnTheGo/Models/AidenInstallation.swift index b94f799c5..59cdbad90 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, ]) let existingNonProgress = Set(installations[index].deviceCapabilities).subtracting(progress) diff --git a/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift b/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift index 3046af711..a11c2b79f 100644 --- a/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift +++ b/ios/AidenOnTheGo/Networking/AidenRemoteClient.swift @@ -324,6 +324,12 @@ struct AidenServer: Codable, Equatable, Sendable { serverCapabilities?.contains(.botHandoffs) == true } + /// The Mac offers `bot:handoff-notes` (revision 38), so this device may ask + /// for it. Only a Mac that also serves `bot:handoffs` offers it. + var offersBotHandoffNotes: Bool { + offersBotHandoffs && serverCapabilities?.contains(.botHandoffNotes) == true + } + /// This device holds `bot:handoffs`: show hand-off cards and the paused /// state, and offer Pause and Resume. var supportsBotHandoffs: Bool { @@ -1056,6 +1062,7 @@ final class AidenRemoteClient: @unchecked Sendable { let allowed = Set([ AidenRemoteCapability.tasksRead, .agentsRead, .questionsRespond, .skillsInvoke, .runsObserve, .runsControl, .simulatorsMobile, .botCards, .botActivity, .botHandoffs, + .botHandoffNotes, .appControlsRead, .appControlsRespond, ]) guard !accepts.isEmpty, diff --git a/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift b/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift index 85173aa02..2495de43a 100644 --- a/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift +++ b/ios/AidenOnTheGo/Networking/AidenRemoteContract.swift @@ -668,10 +668,12 @@ struct AidenRemoteCapability: RawRepresentable, Codable, Hashable, Sendable { /// Contract revision 30 adds `bot:activity`, which only opts the device into /// the activity and queued session frames and Bot detail `chatActivity`. /// Contract revision 37 adds `bot:handoffs`, asked for in a call of its own. + /// Contract revision 38 adds `bot:handoff-notes`, asked for with it. 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: "bot:handoff-notes"), ] /// Revision 27: the device renders `memory_update` and `routine_proposal` @@ -689,6 +691,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 38: a hand-off card that takes no note says so (`takesNote`). + /// It grants no authority. A Mac on revision 37 refuses the name, so the + /// device asks for it only from a Mac that offers it. + static let botHandoffNotes = Self(rawValue: "bot:handoff-notes") + init(from decoder: Decoder) throws { let value = try decoder.singleValueContainer().decode(String.self) guard !value.isEmpty, value.unicodeScalars.count <= AidenRemoteProtocol.maxEventTypeLength else { diff --git a/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift b/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift index 2d5342c62..9b549e553 100644 --- a/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift +++ b/ios/AidenOnTheGoTests/AidenRemoteClientTests.swift @@ -4440,6 +4440,86 @@ final class AidenRemoteClientTests: XCTestCase { } } + /// Contract revision 38: 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" + ) + } + } + @MainActor func testSuccessfulStagedPairingPersistsNegotiatedBotSupportBeforeConnecting() async throws { let keychain = AidenRemoteMemoryKeychain() 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..8c995b9ae 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 38). */ + 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-protocol.ts b/main/services/aiden-remote-protocol.ts index 4ea475f0a..4464df6cb 100644 --- a/main/services/aiden-remote-protocol.ts +++ b/main/services/aiden-remote-protocol.ts @@ -89,10 +89,11 @@ export const AIDEN_REMOTE_PROTOCOL_VERSION = 1 as const; * * Contract revision 38 (provisional; re-claim the next revision after `main`'s * at merge by changing only `AIDEN_REMOTE_BOT_HANDOFF_NOTE_CONTRACT_REVISION`): - * a hand-off card that takes no note (one a password-or-code rule opened) - * carries `takesNote: false`, so a holder hides its note box instead of - * learning from a `400` that the note was refused. Revision 37 shipped in no - * release, so the field needs no opt-in of its own. + * 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_HANDOFFS_CONTRACT_REVISION = 37 as const; export const AIDEN_REMOTE_BOT_HANDOFF_NOTE_CONTRACT_REVISION = 38 as const; @@ -271,9 +272,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 38: `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]; diff --git a/main/services/aiden-remote-router.test.ts b/main/services/aiden-remote-router.test.ts index 1383e3c4c..f54ec8b7d 100644 --- a/main/services/aiden-remote-router.test.ts +++ b/main/services/aiden-remote-router.test.ts @@ -235,7 +235,8 @@ async function fixture(options: { capability !== "runs:control" && capability !== "bot:activity" && capability !== "bot:cards" && - capability !== "bot:handoffs", + capability !== "bot:handoffs" && + capability !== "bot:handoff-notes", ) ) { return null; @@ -5731,9 +5732,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({ @@ -5744,13 +5746,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(); @@ -5767,7 +5771,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(); } diff --git a/main/services/aiden-remote-router.ts b/main/services/aiden-remote-router.ts index ad0ab9636..a691e06f2 100644 --- a/main/services/aiden-remote-router.ts +++ b/main/services/aiden-remote-router.ts @@ -1695,6 +1695,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 38). */ +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, @@ -2430,6 +2438,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; @@ -2451,6 +2460,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; diff --git a/main/services/peer-host-registry.test.ts b/main/services/peer-host-registry.test.ts index 82394cd20..14b1960c5 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 38: 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..48431bc46 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 38), 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 Date: Mon, 26 Oct 2026 21:10:44 +0000 Subject: [PATCH 9/9] Keep a host-feed cursor from the old Bot rows stale after an interrupted resync The feed remembered a device's new audience as soon as it opened, so a desktop whose replacement snapshot was cut off kept its old cursor and resumed it on the next connection, leaving a paused Bot shown as unavailable. Switching audience now moves the head past every earlier cursor with an entry no device is sent, and the device's cursors from before the switch always get a snapshot. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01FTLKrZsPKZM2hnxHVxEnak --- main/services/aiden-remote-host-feed.test.ts | 7 +++- main/services/aiden-remote-host-feed.ts | 34 +++++++++++++++++--- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/main/services/aiden-remote-host-feed.test.ts b/main/services/aiden-remote-host-feed.test.ts index 939785532..f5341fc87 100644 --- a/main/services/aiden-remote-host-feed.test.ts +++ b/main/services/aiden-remote-host-feed.test.ts @@ -421,8 +421,13 @@ test("a desktop that gains bot:handoffs on reconnect gets a fresh snapshot inste 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, frames.slice(-1)[0]!.id); + 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. diff --git a/main/services/aiden-remote-host-feed.ts b/main/services/aiden-remote-host-feed.ts index 602d66842..e57a80030 100644 --- a/main/services/aiden-remote-host-feed.ts +++ b/main/services/aiden-remote-host-feed.ts @@ -128,8 +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; its cursor resumes only those. */ - private readonly audiences = 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) { @@ -190,9 +193,18 @@ export class AidenRemoteHostFeedService { 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. - const previous = this.audiences.get(device.id); - this.audiences.set(device.id, audience); - let position = previous === undefined || previous === audience ? this.resumePosition(cursor) : undefined; + // 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 => { @@ -210,6 +222,7 @@ 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); @@ -372,6 +385,10 @@ export class AidenRemoteHostFeedService { const bytes = Buffer.byteLength(frame); 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) @@ -382,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;