diff --git a/.lane/memory/apps/app/src/attention.ts/01M5J49ZNB9XYK6GFYV3SBXKAP-told-and-called-persist-per.md b/.lane/attic/apps/app/src/attention.ts/01M5J49ZNB9XYK6GFYV3SBXKAP-told-and-called-persist-per.md similarity index 100% rename from .lane/memory/apps/app/src/attention.ts/01M5J49ZNB9XYK6GFYV3SBXKAP-told-and-called-persist-per.md rename to .lane/attic/apps/app/src/attention.ts/01M5J49ZNB9XYK6GFYV3SBXKAP-told-and-called-persist-per.md diff --git a/.lane/memory/apps/app/src/attention.ts/01M5J49W4DPK93Q9SVS23VW59A-mention-counts-are-ignored-u.md b/.lane/memory/apps/app/src/attention.ts/01M5J49W4DPK93Q9SVS23VW59A-mention-counts-are-ignored-u.md index 3c35117a..4a4b9bc3 100644 --- a/.lane/memory/apps/app/src/attention.ts/01M5J49W4DPK93Q9SVS23VW59A-mention-counts-are-ignored-u.md +++ b/.lane/memory/apps/app/src/attention.ts/01M5J49W4DPK93Q9SVS23VW59A-mention-counts-are-ignored-u.md @@ -4,9 +4,9 @@ anchor: function useAttention created: 2026-10-22T15:40:01Z norm: '1' sig: 764bb872be391a4f -body_hash: e8f4fd56e6b15297 -raw_hash: 3466b3d2a2a88367 -vouched: 2026-10-22T20:42:40Z +body_hash: 0d70c33821757895 +raw_hash: 6c2701af357899ff +vouched: 2026-10-23T12:23:27Z lines: 100-195 --- diff --git a/.lane/memory/apps/app/src/attention.ts/01M5MBEFW3PXMMV82S8RKT9VKY-a-question-is-pending-from-t.md b/.lane/memory/apps/app/src/attention.ts/01M5MBEFW3PXMMV82S8RKT9VKY-a-question-is-pending-from-t.md new file mode 100644 index 00000000..d6ea7cc0 --- /dev/null +++ b/.lane/memory/apps/app/src/attention.ts/01M5MBEFW3PXMMV82S8RKT9VKY-a-question-is-pending-from-t.md @@ -0,0 +1,12 @@ +--- +id: 01M5MBEFW3PXMMV82S8RKT9VKY +anchor: function pending +created: 2026-10-23T12:23:22Z +norm: '1' +sig: af3b225fe1618ff5 +body_hash: b9ff227c0ed2e9f3 +raw_hash: 850b81451b16b439 +lines: 62-69 +--- + +a question is pending from the host's ran outcome alone (question until answered/done/failed/stopped), never from this profile's since or seen diff --git a/.lane/memory/apps/app/src/attention.ts/01M5MBEQ6RJJ5BY6QA3FWW6NBP-told-and-called-persist-per.md b/.lane/memory/apps/app/src/attention.ts/01M5MBEQ6RJJ5BY6QA3FWW6NBP-told-and-called-persist-per.md new file mode 100644 index 00000000..50a9f086 --- /dev/null +++ b/.lane/memory/apps/app/src/attention.ts/01M5MBEQ6RJJ5BY6QA3FWW6NBP-told-and-called-persist-per.md @@ -0,0 +1,13 @@ +--- +id: 01M5MBEQ6RJJ5BY6QA3FWW6NBP +anchor: '@file' +created: 2026-10-23T12:23:29Z +norm: '1' +sig: 9d222e7bbdcbbb9b +body_hash: 57bde1c5f58e5bf4 +raw_hash: 5c870af127b25658 +lines: 1-218 +supersedes: 01M5J49ZNB9XYK6GFYV3SBXKAP +--- + +`told` and `called` persist per host and user so reloads and reconnects never re-notify the same run end or mention; `since` keeps history from lighting up on a first visit, but a question still waiting is host state and marks its channel on any profile diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md b/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md index d3e1879c..38669504 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4XQKND0900E94CG3BXBEJP7-a-run-s-end-is-read-from-the.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-14T17:33:22Z norm: '1' sig: c905677fa4be33c1 -body_hash: a3c1f1281c5b3997 -raw_hash: 10f2879f7a794315 -vouched: 2026-10-22T22:28:50Z +body_hash: 6470793c4d7ec520 +raw_hash: b16b1cb88d0267d5 +vouched: 2026-10-23T12:23:27Z lines: 114-792 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md b/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md index d7b9ee08..186b3d44 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4YFFTK7TWGKENJAD1W10HJJ-the-desktop-default-changes.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-15T00:30:42Z norm: '1' sig: c905677fa4be33c1 -body_hash: a3c1f1281c5b3997 -raw_hash: 10f2879f7a794315 -vouched: 2026-10-22T22:28:50Z +body_hash: 6470793c4d7ec520 +raw_hash: b16b1cb88d0267d5 +vouched: 2026-10-23T12:23:27Z lines: 114-792 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md b/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md index 45744ae4..bc2f0705 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4YPJQB6KKEW1YTRD08YCYJY-only-gateways-admit-clients.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-15T02:34:37Z norm: '1' sig: c905677fa4be33c1 -body_hash: a3c1f1281c5b3997 -raw_hash: 10f2879f7a794315 -vouched: 2026-10-22T22:28:50Z +body_hash: 6470793c4d7ec520 +raw_hash: b16b1cb88d0267d5 +vouched: 2026-10-23T12:23:27Z lines: 114-794 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md b/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md index b5ce2ee6..39d3bb66 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M4ZH31DXZK0Y57C97DP84ZKX-handoffs-learns-queued-hando.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-15T10:17:55Z norm: '1' sig: c905677fa4be33c1 -body_hash: a3c1f1281c5b3997 -raw_hash: 10f2879f7a794315 -vouched: 2026-10-22T22:28:50Z +body_hash: 6470793c4d7ec520 +raw_hash: b16b1cb88d0267d5 +vouched: 2026-10-23T12:23:27Z lines: 121-817 --- diff --git a/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md b/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md index 1354d7a4..4b16556a 100644 --- a/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md +++ b/.lane/memory/packages/channel/src/channel.ts/01M53CAGW2RVRKCYF5BCJRTZX2-a-fork-s-starting-lane-is-re.md @@ -4,9 +4,9 @@ anchor: class Channel created: 2026-10-16T22:11:35Z norm: '1' sig: c905677fa4be33c1 -body_hash: a3c1f1281c5b3997 -raw_hash: 10f2879f7a794315 -vouched: 2026-10-22T22:28:50Z +body_hash: 6470793c4d7ec520 +raw_hash: b16b1cb88d0267d5 +vouched: 2026-10-23T12:23:27Z lines: 127-873 --- diff --git a/.lane/memory/packages/channel/src/questions.ts/01M509W675H0FTBQ6HBCAM2TC2-the-question-tool-listens-fo.md b/.lane/memory/packages/channel/src/questions.ts/01M509W675H0FTBQ6HBCAM2TC2-the-question-tool-listens-fo.md index ff1ac716..36941245 100644 --- a/.lane/memory/packages/channel/src/questions.ts/01M509W675H0FTBQ6HBCAM2TC2-the-question-tool-listens-fo.md +++ b/.lane/memory/packages/channel/src/questions.ts/01M509W675H0FTBQ6HBCAM2TC2-the-question-tool-listens-fo.md @@ -4,8 +4,9 @@ anchor: class Questions created: 2026-10-15T17:31:05Z norm: '1' sig: b320f3b4b2555f0f -body_hash: 8d55156e0bb23b9d -raw_hash: aa89381794b072eb +body_hash: db6d80cd41b3614e +raw_hash: 0a114ba9938f4f7d +vouched: 2026-10-23T12:23:27Z lines: 86-161 --- diff --git a/.lane/memory/packages/channel/src/questions.ts/01M5MBEFYAQEH88T5212ES7WJX-onask-hears-answered-when-th.md b/.lane/memory/packages/channel/src/questions.ts/01M5MBEFYAQEH88T5212ES7WJX-onask-hears-answered-when-th.md new file mode 100644 index 00000000..85ad046c --- /dev/null +++ b/.lane/memory/packages/channel/src/questions.ts/01M5MBEFYAQEH88T5212ES7WJX-onask-hears-answered-when-th.md @@ -0,0 +1,12 @@ +--- +id: 01M5MBEFYAQEH88T5212ES7WJX +anchor: class Questions +created: 2026-10-23T12:23:22Z +norm: '1' +sig: b320f3b4b2555f0f +body_hash: db6d80cd41b3614e +raw_hash: 0a114ba9938f4f7d +lines: 89-167 +--- + +onAsk hears answered when the tool's wait resolves (answer, skip or a queued message), never on abort, which the run's end already publishes diff --git a/apps/app/src/attention.ts b/apps/app/src/attention.ts index 8f9c87aa..7a997600 100644 --- a/apps/app/src/attention.ts +++ b/apps/app/src/attention.ts @@ -11,8 +11,9 @@ import { host } from "./host"; /** * Run ends and messages this person has seen or been told about, by channel, and how many mentions * of them each channel had when they last saw it (`heard`) or were told (`called`). Anything before - * `since` predates this browser profile's first visit, so history never lights up. Profiles from - * before mentions have no `heard`, and from before mention notices no `called`. + * `since` predates this browser profile's first visit, so history never lights up; a question still + * waiting is host state, not history, so it marks its channel however old. Profiles from before + * mentions have no `heard`, and from before mention notices no `called`. */ type State = { since: number; @@ -22,7 +23,7 @@ type State = { called?: Record; }; -export type Outcome = Exclude; +export type Outcome = Exclude; /** A run this person invoked, or `message` for anything anyone else wrote since they last looked. */ export type Mark = Outcome | "message"; @@ -54,10 +55,15 @@ host.subscribe(() => { const seenAt = (channel: Listing, state: State) => Math.max(state.since, state.seen[channel.id] || 0); -/** A stopped run was a participant's own doing, so it needs no attention. */ +/** + * A stopped run was a participant's own doing, so it needs no attention. A question needs it until + * the host hears an answer, whenever or wherever this person last looked. + */ function pending(channel: Listing, user: string, state: State): Outcome | undefined { const { ran } = channel; - if (!ran || ran.author !== user || ran.outcome === "stopped") return; + if (!ran || ran.author !== user) return; + if (ran.outcome === "stopped" || ran.outcome === "answered") return; + if (ran.outcome === "question") return ran.outcome; if (ran.at <= seenAt(channel, state)) return; return ran.outcome; } @@ -102,7 +108,7 @@ function missed(found: { channel: Listing; count: number }[]): [string, string] * Runs this person invoked that ended, or that wait on a question, while they were not watching the * channel, teammates' runs that ended in their channels, and new @mentions of `names` (lowercased * GitHub logins). Each pulses the window's glow or sends one desktop notification, then marks its - * row until they open it; mentions that arrived while Ace was closed or disconnected notify once + * row until they open it, or for a question until it is answered; mentions that arrived while Ace was closed or disconnected notify once * together. Others' messages mark the row too, without a notification. */ export function useAttention( @@ -151,7 +157,8 @@ export function useAttention( if (!ran) continue; const outcome = pending(channel, user, state); if (!outcome && !isTeammateRun(channel, user, state)) continue; - if (ran.at <= (state.told[id] || 0)) continue; + // A question seen while it waited, or older than this profile, was already in front of them. + if (ran.at <= Math.max(state.told[id] || 0, seenAt(channel, state))) continue; told[id] = ran.at; if (watching) setPulse("on"); else if (outcome) desktop?.notify(channel.name, NOTICES[outcome]).catch(() => {}); diff --git a/docs/architecture.md b/docs/architecture.md index 2ceefa10..4f277c2d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -667,9 +667,10 @@ Notifications are not built. Each app notices its own person's finished and failed runs, and runs waiting on a question, from listings: it keeps the run ends it has shown or announced per host and participant in browser storage, starting from its first visit. A run end that arrives while another channel is open pulses the window's glow; one that arrives while the window is unfocused -sends a desktop notification. The channel's row keeps a dot (finished) or attention mark (failed -or waiting on a question) -until the person opens it in a focused window. Stopped runs need no attention. +sends a desktop notification. The channel's row keeps a dot (finished) or attention mark (failed) +until the person opens it in a focused window. A run waiting on a question keeps its attention mark +until the host publishes that it was answered, so every browser and phone shows it, even one first +opened after the question was asked. Stopped runs need no attention. ## The team diff --git a/packages/channel/src/channel.ts b/packages/channel/src/channel.ts index e0318731..a1927dad 100644 --- a/packages/channel/src/channel.ts +++ b/packages/channel/src/channel.ts @@ -206,7 +206,7 @@ export class Channel { registry.install(CodingTools); const handoffs = new Handoffs(options.directory); registry.install(handoffs.extension); - const questions = new Questions((chat) => void channel.#asked(chat)); + const questions = new Questions((chat, phase) => void channel.#asked(chat, phase)); registry.install(questions.extension); const execute = options.desktop; if (execute) { @@ -596,15 +596,15 @@ export class Channel { await this.#publishRun(chat, inputs.map((id) => settled.get(id)?.entry), outcome); } - /** A run waiting on a question needs the same attention as one that ended. */ - async #asked(chat: ConversationId): Promise { + /** A run waiting on a question needs the same attention as one that ended, until it is answered. */ + async #asked(chat: ConversationId, phase: "question" | "answered"): Promise { const inputs = this.#runs.get(chat); if (!inputs) return; const entries = await Promise.all(inputs.map(async (id) => { const submission = await this.#harness.submission(id, context); return (await submission?.status(context))?.entry; })); - await this.#publishRun(chat, entries, "question"); + await this.#publishRun(chat, entries, phase); } async #publishRun( diff --git a/packages/channel/src/protocol.ts b/packages/channel/src/protocol.ts index ae930f0c..1475d3c5 100644 --- a/packages/channel/src/protocol.ts +++ b/packages/channel/src/protocol.ts @@ -31,8 +31,11 @@ export type Metadata = { name: string; summary: string; revision: number }; export type RunEnd = { chat: ChatId; at: number; - /** `question` is published when a run starts waiting on a question, not at its end. */ - outcome: "done" | "failed" | "stopped" | "question"; + /** + * `question` is published when a run starts waiting on a question and `answered` when that wait + * ends with the run still going, so a pending question is read from the host, not from a client. + */ + outcome: "done" | "failed" | "stopped" | "question" | "answered"; author: string; }; diff --git a/packages/channel/src/questions.ts b/packages/channel/src/questions.ts index 7cd8a598..0639dbec 100644 --- a/packages/channel/src/questions.ts +++ b/packages/channel/src/questions.ts @@ -89,9 +89,10 @@ function queued(state: Readonly | null | undefined): string | undefi export class Questions { #queued = new Map(); #waiting = new Map void>>(); - #onAsk: (chat: ConversationId) => void; + #onAsk: (chat: ConversationId, phase: "question" | "answered") => void; - constructor(onAsk: (chat: ConversationId) => void) { + /** `onAsk` hears when a chat starts waiting on a question and when an answer or message ends it. */ + constructor(onAsk: (chat: ConversationId, phase: "question" | "answered") => void) { this.#onAsk = onAsk; } @@ -131,7 +132,7 @@ export class Questions { doc.questions[call] = { items: args.questions }; return true; }, context); - if (created) this.#onAsk(chat); + if (created) this.#onAsk(chat, "question"); const watch = (await api.watchDoc(QuestionsDoc, chat, context))!; const settle = (value: typeof watch.value) => { const answer = value?.questions[call]?.answer; @@ -143,7 +144,9 @@ export class Questions { if (context.abortSignal?.aborted) abort(); settle(watch.value); watch.start(async (value) => settle(value)); - return await promise; + const result = await promise; + this.#onAsk(chat, "answered"); + return result; } finally { context.abortSignal?.removeEventListener("abort", abort); await watch.stop();