From 199a054f18a7024ef7672f88d84af8c7975493c4 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Wed, 26 Aug 2026 02:58:00 +0300 Subject: [PATCH 1/2] Only a merged or closed pull request is landed; unread and idle are visited once on change, awaiting again on age (#1707) --- FEATURES-SPEC.md | 2 +- packages/chrome-extension/SPEC.md | 2 +- packages/chrome-extension/background.js | 2 +- packages/chrome-extension/check.SPEC.md | 4 +- packages/chrome-extension/check.mjs | 85 +++++++++++++------ packages/chrome-extension/content.SPEC.md | 4 +- packages/chrome-extension/content.js | 9 +- packages/chrome-extension/driver-plan.SPEC.md | 13 +-- packages/chrome-extension/driver-plan.js | 21 +++-- 9 files changed, 95 insertions(+), 47 deletions(-) diff --git a/FEATURES-SPEC.md b/FEATURES-SPEC.md index 0c71a7bd..f9d91d09 100644 --- a/FEATURES-SPEC.md +++ b/FEATURES-SPEC.md @@ -167,7 +167,7 @@ happens while nobody is at the keyboard. - Run on a Claude Code cloud session - A web run's cloud session is created by the Chrome extension in your own browser, through claude.ai's repository picker — repo-bound, so it can push and open its pull request; without the extension (or with the browser bridge off) the run stops and says which is missing - Chrome extension bridging claude.ai questions back to the dashboard -- One pinned "The Framework Driver" tab serves every recent cloud session: it reads claude.ai's own session list (the status beside each session), visits only the sessions awaiting input, unread, or holding a queued answer — navigating inside the app, one page load a minute — and shows a full-page overlay naming what it is, with collapsible debug logs; closing the tab pauses the bridge until the extension's options page reopens it or the browser restarts +- One pinned "The Framework Driver" tab serves every recent cloud session: it reads claude.ai's own session list (the status beside each session), visits a session when the list's word for it changed to awaiting input, unread or idle, an awaiting one again every five minutes, and any session holding a queued answer — navigating inside the app, one page load a minute — and shows a full-page overlay naming what it is, with collapsible debug logs; closing the tab pauses the bridge until the extension's options page reopens it or the browser restarts - A cloud run's row says "waiting" when claude.ai's session list shows its session awaiting input, even when the question was asked in prose rather than as a choice block - A cloud session's conversation mirrored into the run view, turn by turn, as it is written - Answer a cloud agent's question from the dashboard (typed back into claude.ai) — the same gate panel a local agent gets, multi-select and stop options included, listed with every other open question diff --git a/packages/chrome-extension/SPEC.md b/packages/chrome-extension/SPEC.md index e4d81cd9..60f95788 100644 --- a/packages/chrome-extension/SPEC.md +++ b/packages/chrome-extension/SPEC.md @@ -75,7 +75,7 @@ See `## User story`, first item — and it has to hold for fifty sessions as it #### Business logic -The extension only sees pages it is injected into, so it cannot know an agent started. The daemon publishes which cloud sessions are its — every web run's session of the last twelve hours, each flagged with whether an answer is queued — and the extension keeps one pinned, inactive background tab, the Driver, open on claude.ai: content scripts run in background tabs, so the bridge works while Chrome merely runs. Twice a minute the Driver reads the session list — each of the daemon's sessions with the status claude.ai shows beside it — reports those statuses to the daemon, and visits only the sessions the planner picks — a handful a cycle, answers first: the parked ones (awaiting input or unread) whose status changed or that have not been looked at for a while, and any holding a queued answer. A visit is an in-app navigation, clicking the session's row and later the list's "New" link back, never a page load; the list itself is refreshed by one page load a minute, since claude.ai's list refreshes only on a load. A visited session is read and mirrored as any page is, and typed into when an answer travelled with the visit. Running the Driver is opt-in from the options page. Closing the Driver tab pauses the bridge until the options page reopens it or the browser restarts; the user's own claude.ai tabs are never navigated or typed into — with one exception the extension cannot avoid: after a browser restart, a lone pinned claude.ai sessions tab is taken to be the Driver Chrome restored, since a restored tab carries no other mark. +The extension only sees pages it is injected into, so it cannot know an agent started. The daemon publishes which cloud sessions are its — every web run's session of the last twelve hours, each flagged with whether an answer is queued — and the extension keeps one pinned, inactive background tab, the Driver, open on claude.ai: content scripts run in background tabs, so the bridge works while Chrome merely runs. Twice a minute the Driver reads the session list — each of the daemon's sessions with the status claude.ai shows beside it — reports those statuses to the daemon, and visits only the sessions the planner picks — a handful a cycle, answers first: the ones whose status changed to a stopped one (awaiting input, unread or idle), the awaiting ones not looked at for five minutes, and any holding a queued answer. A visit is an in-app navigation, clicking the session's row and later the list's "New" link back, never a page load; the list itself is refreshed by one page load a minute, since claude.ai's list refreshes only on a load. A visited session is read and mirrored as any page is, and typed into when an answer travelled with the visit. Running the Driver is opt-in from the options page. Closing the Driver tab pauses the bridge until the options page reopens it or the browser restarts; the user's own claude.ai tabs are never navigated or typed into — with one exception the extension cannot avoid: after a browser restart, a lone pinned claude.ai sessions tab is taken to be the Driver Chrome restored, since a restored tab carries no other mark. The Driver tab shows a full-page overlay in place of claude.ai's interface: a heading naming it "The Framework Driver", a line saying what the tab is for and that closing it pauses the bridge, and a collapsed "Show debug logs" holding the cycle log — what was read, visited, typed and created, and what the daemon answered. diff --git a/packages/chrome-extension/background.js b/packages/chrome-extension/background.js index 702c9d6f..06e15b08 100644 --- a/packages/chrome-extension/background.js +++ b/packages/chrome-extension/background.js @@ -381,7 +381,7 @@ async function runCycle() { const now = Date.now() const planned = planVisits(statuses, answers, seen, now) - // Answers first, then a bounded handful of parked sessions; what is cut waits for the next + // Answers first, then a bounded handful of the due sessions; what is cut waits for the next // beat, with the change that made it due kept pending below. const visits = [...planned.filter(v => v.answer), ...planned.filter(v => !v.answer)].slice(0, MAX_VISITS) for (const visit of visits) if (visit.answer) deliveredAnswers.add(visit.answer.id) diff --git a/packages/chrome-extension/check.SPEC.md b/packages/chrome-extension/check.SPEC.md index 738ddd3b..3083993e 100644 --- a/packages/chrome-extension/check.SPEC.md +++ b/packages/chrome-extension/check.SPEC.md @@ -46,9 +46,9 @@ The Driver, on a synthetic app built like the live one was observed to be — a - A session missing from the list is not visited and its answer is not claimed. - The session the worker asked for is created first, from the list page, before the visits, and the cycle still ends on the list. - A second instruction while a drive runs is refused as busy, and the drive completes. -- A status word on a row beats its pull-request label. +- A status word on a row beats its pull-request label; a row showing only a pull request is idle when the pull request is open and landed when it is merged or closed. -The visit planner: a parked session is visited when never seen, when its status changed, and again after five minutes; a queued answer forces a visit whatever the status; idle, running, landed and missing sessions are never visited on their own. +The visit planner: an awaiting, unread or idle session is visited when never seen and when its status changed; after five minutes unchanged only an awaiting session is visited again, an unread or idle one is not; a queued answer forces a visit whatever the status; running, landed and missing sessions are never visited on their own. ## Rationale diff --git a/packages/chrome-extension/check.mjs b/packages/chrome-extension/check.mjs index 62b526a8..6f23fad5 100644 --- a/packages/chrome-extension/check.mjs +++ b/packages/chrome-extension/check.mjs @@ -691,6 +691,29 @@ function appPage({ sessions = SESSIONS, firstPage = 6, sendAppendsRow = true } = dom.window.close() } +{ + // A row carrying only a pull-request label: the work landed when the pull request is merged or + // closed; an open pull request is a session that went quiet, so it is idle — visited when its + // row changes, never for its age (#1707). + const sessions = [ + { id: 'session_01PROPEN', label: '#1700 · Open', title: 'Pull request open' }, + { id: 'session_01PRMERGED', label: '#1701 · Merged', title: 'Pull request merged' }, + { id: 'session_01PRCLOSED', label: '#1702 · Closed', title: 'Pull request closed' }, + ] + const { dom, w } = appPage({ sessions }) + const got = await w.__tfBridgeReadSessionList(sessions.map(s => s.id)) + const statuses = got.statuses.map(s => [s.sessionId, s.status]) + const want = [ + ['session_01PROPEN', 'idle'], + ['session_01PRMERGED', 'landed'], + ['session_01PRCLOSED', 'landed'], + ] + const ok = JSON.stringify(statuses) === JSON.stringify(want) + if (!ok) failed++ + console.log(`${ok ? 'PASS' : 'FAIL'} an open pull request alone reads as idle, a merged or closed one as landed (${JSON.stringify(statuses)})`) + dom.window.close() +} + // --------------------------------------------------------------------------- // The script's own drawing is not a page change (#1707). The corner panel is redrawn on every // survey and the Driver overlay on every log line, both under the observed root — so unless the @@ -720,8 +743,9 @@ function appPage({ sessions = SESSIONS, firstPage = 6, sendAppendsRow = true } = // --------------------------------------------------------------------------- // Which sessions a cycle visits (driver-plan.js): the statuses are sticky — an in-app visit clears -// neither "Awaiting input" nor "Unread response" — so a parked session is visited on a change, -// after a while, and always when an answer is queued; the rest are never visited. +// neither "Awaiting input" nor "Unread response" — so a session is visited when its status changed +// to awaiting, unread or idle, only awaiting is visited again on age, and an answer always earns a +// visit; running, landed and missing sessions are never visited on their own (#1707). { const plan = readFileSync(join(here, 'driver-plan.js'), 'utf8') @@ -738,30 +762,43 @@ function appPage({ sessions = SESSIONS, firstPage = 6, sendAppendsRow = true } = { sessionId: 's_missing', status: 'missing' }, ] const ids = visits => visits.map(v => v.id) - // Never seen: every parked session is due, nothing else is. - const first = planVisits(statuses, new Map(), new Map(), NOW) // Seen a moment ago with the same status: nothing is due. const recent = new Map(statuses.map(s => [s.sessionId, { status: s.status, visitedAt: NOW - 1000 }])) - const second = planVisits(statuses, new Map(), recent, NOW) - // The same, six minutes later: the parked ones are due again. - const later = planVisits(statuses, new Map(), recent, NOW + 6 * 60_000) - // A status change makes a parked session due at once. - const changed = new Map(recent) - changed.set('s_unread', { status: 'idle', visitedAt: NOW - 1000 }) - const onChange = planVisits(statuses, new Map(), changed, NOW) - // An answer forces a visit whatever the status, and travels with it. - const answers = new Map([['s_idle', { id: 'a1', text: 't' }]]) - const forced = planVisits(statuses, answers, recent, NOW) - const ok = - JSON.stringify(ids(first)) === JSON.stringify(['s_await', 's_unread']) && - second.length === 0 && - JSON.stringify(ids(later)) === JSON.stringify(['s_await', 's_unread']) && - JSON.stringify(ids(onChange)) === JSON.stringify(['s_unread']) && - JSON.stringify(forced) === JSON.stringify([{ id: 's_idle', status: 'idle', answer: { id: 'a1', text: 't' } }]) - if (!ok) failed++ - console.log( - `${ok ? 'PASS' : 'FAIL'} visits are planned on change, on age, and on a queued answer; idle, running, landed and missing sessions are left alone (first=${ids(first)}, second=${second.length}, later=${ids(later)}, change=${ids(onChange)}, forced=${JSON.stringify(forced)})`, - ) + { + // Never seen: the awaiting, unread and idle sessions are due, nothing else is. + const first = planVisits(statuses, new Map(), new Map(), NOW) + const second = planVisits(statuses, new Map(), recent, NOW) + // A status change makes each of those due at once — the unread one from idle, the idle one + // from running — and leaves a running session alone. + const changed = new Map(recent) + changed.set('s_unread', { status: 'idle', visitedAt: NOW - 1000 }) + changed.set('s_idle', { status: 'running', visitedAt: NOW - 1000 }) + changed.set('s_run', { status: 'idle', visitedAt: NOW - 1000 }) + const onChange = planVisits(statuses, new Map(), changed, NOW) + // An answer forces a visit whatever the status, and travels with it. + const answers = new Map([['s_landed', { id: 'a1', text: 't' }]]) + const forced = planVisits(statuses, answers, recent, NOW) + const ok = + JSON.stringify(ids(first)) === JSON.stringify(['s_await', 's_unread', 's_idle']) && + second.length === 0 && + JSON.stringify(ids(onChange)) === JSON.stringify(['s_unread', 's_idle']) && + JSON.stringify(forced) === JSON.stringify([{ id: 's_landed', status: 'landed', answer: { id: 'a1', text: 't' } }]) + if (!ok) failed++ + console.log( + `${ok ? 'PASS' : 'FAIL'} awaiting, unread and idle sessions are visited when never seen or on a status change, an answer forces a visit; running, landed and missing sessions are left alone (first=${ids(first)}, second=${second.length}, change=${ids(onChange)}, forced=${JSON.stringify(forced)})`, + ) + } + { + // The same list six minutes later, nothing changed: only the awaiting session is due again. + // The unread one was visited once when it turned unread; re-reading it every five minutes + // for the whole window would mirror nothing new (#1707). + const later = planVisits(statuses, new Map(), recent, NOW + 6 * 60_000) + // And one minute later nothing is: the age revisit is five minutes, not every beat. + const soon = planVisits(statuses, new Map(), recent, NOW + 60_000) + const ok = JSON.stringify(ids(later)) === JSON.stringify(['s_await']) && soon.length === 0 + if (!ok) failed++ + console.log(`${ok ? 'PASS' : 'FAIL'} after five minutes unchanged, only an awaiting session is visited again; unread and idle are not (later=${ids(later)}, soon=${soon.length})`) + } dom.window.close() } diff --git a/packages/chrome-extension/content.SPEC.md b/packages/chrome-extension/content.SPEC.md index 4b25481d..d4b90381 100644 --- a/packages/chrome-extension/content.SPEC.md +++ b/packages/chrome-extension/content.SPEC.md @@ -27,7 +27,7 @@ A `web`-target agent hands its task to a cloud session and ends, so when that se - **The panel says which step failed** - what was found, where, and what the daemon said, with structure-only counters when nothing was found. - **A session is created through the page's own controls** - the composer's chips (repository, then branch), each opening a searchable list; the page remembers the last repository picked, so the chips are waited for and read rather than assumed; the branch chip must read the requested branch before anything is sent, the session id is read from the address the page becomes, and a failure names the control that was missing. - **It watches the page rather than polling it** - the session's own changes trigger a re-read immediately; a slow heartbeat is only a backstop. -- **In the Driver tab it reads the session list** - each of the daemon's sessions becomes a status by the label beside its row (awaiting input, unread, idle, running, landed, or an unknown label carried verbatim), paging the list through its own "Show more" button until every session is found or the list ends; a session not on it is reported missing. +- **In the Driver tab it reads the session list** - each of the daemon's sessions becomes a status by the label beside its row (awaiting input, unread, idle, running, landed, or an unknown label carried verbatim; a row showing only a pull request is landed when that pull request is merged or closed and idle when it is still open), paging the list through its own "Show more" button until every session is found or the list ends; a session not on it is reported missing. - **Visits happen inside the app** - a session is visited by clicking its row and, afterwards, the "New" link back to the list, never by loading a page; a visited session is surveyed and mirrored like any other — once its own transcript is on the page, not the previous session's — and an answer that travelled with the visit is typed and counted as sent only once the page took the send. - **The Driver overlay** - the Driver tab is covered by a full-page overlay naming it, saying what it is for, and holding the cycle log behind a collapsed "Show debug logs"; it is re-asserted after any page change and replaces the bridge panel there. - **One instruction at a time** - a list read or a drive still running makes the next instruction a refusal, never a second drive over the first. @@ -160,7 +160,7 @@ One pinned tab has to serve fifty cloud sessions: find the few waiting on the us This half becomes the Driver when the worker's reply to its self-report — sent once when any page loads, session or not — says so, or when the worker's first instruction arrives. From then on the overlay is up and the bridge panel is hidden, while the page keeps being surveyed and mirrored as any other. -Reading the list: the list is fetched after the page reports loaded, so it is waited for first; a page with no session rows at all — signed out, or not on the sessions page — is named as such rather than read as every session missing. For the session ids the worker names, every row of the session list is read — a row is a link to its session, so the id is in its address; a link to a session elsewhere on the page does not stand in for a row that carries a status — and the label of the status icon beside it becomes the session's status: "Awaiting input" is awaiting, "Unread response" is unread, "Idle" is idle, "Running" is running, a label naming a pull request and its state is landed only when no status word is on the row, and any other label is unknown and carried verbatim, so a label the bridge does not know is named rather than guessed at. While any named session is not on the list and the list offers a "Show more" button of its own — the one within a few levels of the rows, never the one in the page's middle panel — it is clicked and the list read again, up to ten times, stopping when a click adds no rows. A session still not on the list is reported missing. +Reading the list: the list is fetched after the page reports loaded, so it is waited for first; a page with no session rows at all — signed out, or not on the sessions page — is named as such rather than read as every session missing. For the session ids the worker names, every row of the session list is read — a row is a link to its session, so the id is in its address; a link to a session elsewhere on the page does not stand in for a row that carries a status — and the label of the status icon beside it becomes the session's status: "Awaiting input" is awaiting, "Unread response" is unread, "Idle" is idle, "Running" is running, a label naming a pull request and its state stands in only when no status word is on the row — "Merged" or "Closed" is landed, "Open" is idle — and any other label is unknown and carried verbatim, so a label the bridge does not know is named rather than guessed at. While any named session is not on the list and the list offers a "Show more" button of its own — the one within a few levels of the rows, never the one in the page's middle panel — it is clicked and the list read again, up to ten times, stopping when a click adds no rows. A session still not on the list is reported missing. Visiting: each visit the worker asks for is made by clicking the session's row, waiting for the page's address to name that session and for transcript rows that are not the previous session's to appear — the address changes before the page does — then running the survey, so the parked question and the transcript reach the daemon exactly as from any page. When an answer travelled with the visit, it is typed and submitted the way any answer is, and then the page is given a while to take the send: the delivery counts as sent only once the composer is empty again and the transcript holds a turn row it did not before — a new row, not a higher count, since a long transcript keeps only its tail rendered — and otherwise is reported as failed, naming what the page did. A session not on the list is not visited and its answer is left untouched. When the worker also asked for a session to be created, that comes first, on the list page the cycle starts from and through the same steps as any creation — a run is waiting on it; then the visits; then the "New" link takes the page back to the list without a load. The worker gets back what the creation did, what each visit found, and what each delivery did. While a list read or a drive is running, a second instruction is refused as busy rather than run alongside it — two drives in one page would navigate over each other; it happens when the worker that sent the first ended mid-cycle, since the page drives on regardless. diff --git a/packages/chrome-extension/content.js b/packages/chrome-extension/content.js index b71842d1..afe05d73 100644 --- a/packages/chrome-extension/content.js +++ b/packages/chrome-extension/content.js @@ -771,8 +771,12 @@ const LIST_LABELS = new Map([ ['Idle', 'idle'], ['Running', 'running'], ]) -/** Once the session's work landed, its pull request and state stand in for the status. */ -const LANDED_LABEL = /^#\d+ · / +/** + * A pull request's label stands in for the status only with no status word on the row: merged or + * closed, the session's work landed; still open, the session went quiet and is idle (#1707). + */ +const LANDED_LABEL = /^#\d+ · (Merged|Closed)\b/ +const OPEN_LABEL = /^#\d+ · Open\b/ const NAV_WAIT_MS = 15000 const ROWS_WAIT_MS = 20000 const SEND_WAIT_MS = 15000 @@ -837,6 +841,7 @@ function statusOf(anchor) { // Only with no status word on the row does its pull request stand in for one: a row can carry // both, and "Awaiting input" beside a pull request is still a session stopped for its user. if (labels.some(label => LANDED_LABEL.test(label))) return { status: 'landed' } + if (labels.some(label => OPEN_LABEL.test(label))) return { status: 'idle' } return { status: 'unknown', label: (labels[0] ?? '').slice(0, 80) } } diff --git a/packages/chrome-extension/driver-plan.SPEC.md b/packages/chrome-extension/driver-plan.SPEC.md index f6ef5126..4bb1539b 100644 --- a/packages/chrome-extension/driver-plan.SPEC.md +++ b/packages/chrome-extension/driver-plan.SPEC.md @@ -1,4 +1,4 @@ -Decides which of the cloud sessions the daemon lists the Driver tab visits in a given cycle: the parked ones whose state changed or has not been looked at for a while, and any session holding a queued answer. +Decides which of the cloud sessions the daemon lists the Driver tab visits in a given cycle: the ones whose list status changed to a stopped one, the awaiting ones not looked at for a while, and any session holding a queued answer. ## User story @@ -6,13 +6,14 @@ Fifty cloud sessions are running. The dashboard user wants every question those ## Glossary -- **parked** — a session claude.ai's session list shows as "Awaiting input" (it stopped to ask its user) or "Unread response" (it finished a turn nobody has read). +- **stopped** — a session claude.ai's session list shows as "Awaiting input" (it stopped to ask its user), "Unread response" (it finished a turn nobody has read) or idle (nothing is happening in it — the list's "Idle", or a row showing only an open pull request). ## Business logic — TL;DR - **A queued answer always earns a visit** - whatever the list says about the session; the answer is the one thing the list cannot know about. -- **A parked session is visited on change, and again after a while** - when its list status differs from the last read, or five minutes have passed since it was last visited. -- **Everything else is left alone** - idle, running, landed and missing sessions are never visited without an answer to deliver. +- **A stopped session is visited when it changes** - once, when its list status differs from the last read or it has never been read. +- **Only an awaiting session is visited again on age** - when five minutes have passed since it was last visited, even with no change. +- **Everything else is left alone** - running, landed and missing sessions are never visited without an answer to deliver. ## Business logic @@ -24,11 +25,11 @@ See `## User story`. #### Business logic -For each session in the order the list gave them: it is visited when an answer is queued for it; otherwise only when it is parked and either its status changed since the last cycle that read it, it has never been visited, or its last visit is five minutes or more ago. The visits carry the session id, its status, and the answer when there is one. +For each session in the order the list gave them: it is visited when an answer is queued for it; otherwise when it is stopped and either its status changed since the last cycle that read it or it has never been visited; and otherwise when it is awaiting and its last visit is five minutes or more ago. The visits carry the session id, its status, and the answer when there is one. #### Rationale -claude.ai's list statuses are sticky: an in-app visit clears neither "Awaiting input" nor "Unread response" (measured on the live page). Visiting every parked session on every cycle would therefore mean fifty visits every half minute for fifty agents. Change and age together keep the cost at a handful of visits per cycle without ever letting a parked session go unlooked-at for long, and the answer rule keeps the user's pick from waiting on either. +claude.ai's list statuses are sticky: an in-app visit clears neither "Awaiting input" nor "Unread response" (measured on the live page). Visiting every stopped session on every cycle would therefore mean fifty visits every half minute for fifty agents, so a change is what earns a visit. The age revisit is kept for awaiting sessions alone: a question asked in prose carries no question block, so the list's word is the only signal that the session is waiting and a re-read is what carries the question home. An unread or idle session has nothing new after its one visit — re-reading it every five minutes for the whole run would mirror nothing — and a session whose pull request is still open is idle rather than landed so that its later change is still seen. ## Before modifying/creating SPEC.md files diff --git a/packages/chrome-extension/driver-plan.js b/packages/chrome-extension/driver-plan.js index 1accd0b1..da58e906 100644 --- a/packages/chrome-extension/driver-plan.js +++ b/packages/chrome-extension/driver-plan.js @@ -2,16 +2,21 @@ // which decides, and the offline harness, which pins the rule; loaded as a plain script in both. // // The rule exists because claude.ai's list statuses are sticky: an in-app visit clears neither -// "Awaiting input" nor "Unread response" (measured 2026-08-25), so visiting every parked session -// on every cycle would mean 50 visits every half minute for 50 agents. A parked session is visited -// when its status changed since the last read, when it has not been visited for a while, and -// always when the dashboard queued an answer for it — that is the one thing the list cannot know. +// "Awaiting input" nor "Unread response" (measured 2026-08-25), so visiting every stopped session +// on every cycle would mean 50 visits every half minute for 50 agents. A session is visited when +// its status changed to awaiting, unread or idle since the last read; an awaiting one also when it +// has not been visited for a while — a question asked in prose carries no block, so the list's +// word is the only signal and a re-read is what catches it (#1707); and any session always when +// the dashboard queued an answer for it — that is the one thing the list cannot know. -/** How long a session the list keeps calling parked waits before being looked at again. */ +/** How long a session the list keeps calling awaiting waits before being looked at again. */ const REVISIT_MS = 5 * 60_000 -/** The list statuses that mean the session stopped for its user. */ -const PARKED = new Set(['awaiting', 'unread']) +/** The list statuses that mean the session stopped: for its user, after a turn, or for good. */ +const ON_CHANGE = new Set(['awaiting', 'unread', 'idle']) + +/** The one status also revisited on age: the session may be asking without a block. */ +const ON_AGE = new Set(['awaiting']) /** * `statuses`: what the list said, `[{sessionId, status}]`. `answers`: a map of session id to the @@ -25,7 +30,7 @@ function planVisits(statuses, answers, seen, now, revisitMs = REVISIT_MS) { const last = seen.get(sessionId) const changed = !last || last.status !== status const stale = !last || now - (last.visitedAt ?? 0) >= revisitMs - const due = PARKED.has(status) && (changed || stale) + const due = (ON_CHANGE.has(status) && changed) || (ON_AGE.has(status) && stale) if (answer || due) visits.push({ id: sessionId, status, ...(answer ? { answer } : {}) }) } return visits From 29684d3a9ed0cb19834becd532f23c1e82660bf4 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Wed, 26 Aug 2026 03:48:59 +0300 Subject: [PATCH 2/2] A draft pull request is idle too: only Merged/Closed are landed, every other pull-request state is idle (#1707) --- packages/chrome-extension/check.SPEC.md | 2 +- packages/chrome-extension/check.mjs | 8 +++++--- packages/chrome-extension/content.SPEC.md | 4 ++-- packages/chrome-extension/content.js | 8 +++++--- packages/chrome-extension/driver-plan.SPEC.md | 2 +- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/chrome-extension/check.SPEC.md b/packages/chrome-extension/check.SPEC.md index 3083993e..bd624d5a 100644 --- a/packages/chrome-extension/check.SPEC.md +++ b/packages/chrome-extension/check.SPEC.md @@ -46,7 +46,7 @@ The Driver, on a synthetic app built like the live one was observed to be — a - A session missing from the list is not visited and its answer is not claimed. - The session the worker asked for is created first, from the list page, before the visits, and the cycle still ends on the list. - A second instruction while a drive runs is refused as busy, and the drive completes. -- A status word on a row beats its pull-request label; a row showing only a pull request is idle when the pull request is open and landed when it is merged or closed. +- A status word on a row beats its pull-request label; a row showing only a pull request is idle when the pull request is open or a draft and landed when it is merged or closed. The visit planner: an awaiting, unread or idle session is visited when never seen and when its status changed; after five minutes unchanged only an awaiting session is visited again, an unread or idle one is not; a queued answer forces a visit whatever the status; running, landed and missing sessions are never visited on their own. diff --git a/packages/chrome-extension/check.mjs b/packages/chrome-extension/check.mjs index 6f23fad5..a04a08bc 100644 --- a/packages/chrome-extension/check.mjs +++ b/packages/chrome-extension/check.mjs @@ -693,10 +693,11 @@ function appPage({ sessions = SESSIONS, firstPage = 6, sendAppendsRow = true } = { // A row carrying only a pull-request label: the work landed when the pull request is merged or - // closed; an open pull request is a session that went quiet, so it is idle — visited when its - // row changes, never for its age (#1707). + // closed; any other state — open, or draft as the live list spelled it on 2026-08-26 — is a + // session that went quiet, so it is idle — visited when its row changes, never for its age (#1707). const sessions = [ { id: 'session_01PROPEN', label: '#1700 · Open', title: 'Pull request open' }, + { id: 'session_01PRDRAFT', label: '#1706 · Draft', title: 'Pull request draft' }, { id: 'session_01PRMERGED', label: '#1701 · Merged', title: 'Pull request merged' }, { id: 'session_01PRCLOSED', label: '#1702 · Closed', title: 'Pull request closed' }, ] @@ -705,12 +706,13 @@ function appPage({ sessions = SESSIONS, firstPage = 6, sendAppendsRow = true } = const statuses = got.statuses.map(s => [s.sessionId, s.status]) const want = [ ['session_01PROPEN', 'idle'], + ['session_01PRDRAFT', 'idle'], ['session_01PRMERGED', 'landed'], ['session_01PRCLOSED', 'landed'], ] const ok = JSON.stringify(statuses) === JSON.stringify(want) if (!ok) failed++ - console.log(`${ok ? 'PASS' : 'FAIL'} an open pull request alone reads as idle, a merged or closed one as landed (${JSON.stringify(statuses)})`) + console.log(`${ok ? 'PASS' : 'FAIL'} an open or draft pull request alone reads as idle, a merged or closed one as landed (${JSON.stringify(statuses)})`) dom.window.close() } diff --git a/packages/chrome-extension/content.SPEC.md b/packages/chrome-extension/content.SPEC.md index d4b90381..450d073a 100644 --- a/packages/chrome-extension/content.SPEC.md +++ b/packages/chrome-extension/content.SPEC.md @@ -27,7 +27,7 @@ A `web`-target agent hands its task to a cloud session and ends, so when that se - **The panel says which step failed** - what was found, where, and what the daemon said, with structure-only counters when nothing was found. - **A session is created through the page's own controls** - the composer's chips (repository, then branch), each opening a searchable list; the page remembers the last repository picked, so the chips are waited for and read rather than assumed; the branch chip must read the requested branch before anything is sent, the session id is read from the address the page becomes, and a failure names the control that was missing. - **It watches the page rather than polling it** - the session's own changes trigger a re-read immediately; a slow heartbeat is only a backstop. -- **In the Driver tab it reads the session list** - each of the daemon's sessions becomes a status by the label beside its row (awaiting input, unread, idle, running, landed, or an unknown label carried verbatim; a row showing only a pull request is landed when that pull request is merged or closed and idle when it is still open), paging the list through its own "Show more" button until every session is found or the list ends; a session not on it is reported missing. +- **In the Driver tab it reads the session list** - each of the daemon's sessions becomes a status by the label beside its row (awaiting input, unread, idle, running, landed, or an unknown label carried verbatim; a row showing only a pull request is landed when that pull request is merged or closed and idle in any other state, open or draft), paging the list through its own "Show more" button until every session is found or the list ends; a session not on it is reported missing. - **Visits happen inside the app** - a session is visited by clicking its row and, afterwards, the "New" link back to the list, never by loading a page; a visited session is surveyed and mirrored like any other — once its own transcript is on the page, not the previous session's — and an answer that travelled with the visit is typed and counted as sent only once the page took the send. - **The Driver overlay** - the Driver tab is covered by a full-page overlay naming it, saying what it is for, and holding the cycle log behind a collapsed "Show debug logs"; it is re-asserted after any page change and replaces the bridge panel there. - **One instruction at a time** - a list read or a drive still running makes the next instruction a refusal, never a second drive over the first. @@ -160,7 +160,7 @@ One pinned tab has to serve fifty cloud sessions: find the few waiting on the us This half becomes the Driver when the worker's reply to its self-report — sent once when any page loads, session or not — says so, or when the worker's first instruction arrives. From then on the overlay is up and the bridge panel is hidden, while the page keeps being surveyed and mirrored as any other. -Reading the list: the list is fetched after the page reports loaded, so it is waited for first; a page with no session rows at all — signed out, or not on the sessions page — is named as such rather than read as every session missing. For the session ids the worker names, every row of the session list is read — a row is a link to its session, so the id is in its address; a link to a session elsewhere on the page does not stand in for a row that carries a status — and the label of the status icon beside it becomes the session's status: "Awaiting input" is awaiting, "Unread response" is unread, "Idle" is idle, "Running" is running, a label naming a pull request and its state stands in only when no status word is on the row — "Merged" or "Closed" is landed, "Open" is idle — and any other label is unknown and carried verbatim, so a label the bridge does not know is named rather than guessed at. While any named session is not on the list and the list offers a "Show more" button of its own — the one within a few levels of the rows, never the one in the page's middle panel — it is clicked and the list read again, up to ten times, stopping when a click adds no rows. A session still not on the list is reported missing. +Reading the list: the list is fetched after the page reports loaded, so it is waited for first; a page with no session rows at all — signed out, or not on the sessions page — is named as such rather than read as every session missing. For the session ids the worker names, every row of the session list is read — a row is a link to its session, so the id is in its address; a link to a session elsewhere on the page does not stand in for a row that carries a status — and the label of the status icon beside it becomes the session's status: "Awaiting input" is awaiting, "Unread response" is unread, "Idle" is idle, "Running" is running, a label naming a pull request and its state stands in only when no status word is on the row — "Merged" or "Closed" is landed, any other state ("Open", "Draft") is idle — and any other label is unknown and carried verbatim, so a label the bridge does not know is named rather than guessed at. While any named session is not on the list and the list offers a "Show more" button of its own — the one within a few levels of the rows, never the one in the page's middle panel — it is clicked and the list read again, up to ten times, stopping when a click adds no rows. A session still not on the list is reported missing. Visiting: each visit the worker asks for is made by clicking the session's row, waiting for the page's address to name that session and for transcript rows that are not the previous session's to appear — the address changes before the page does — then running the survey, so the parked question and the transcript reach the daemon exactly as from any page. When an answer travelled with the visit, it is typed and submitted the way any answer is, and then the page is given a while to take the send: the delivery counts as sent only once the composer is empty again and the transcript holds a turn row it did not before — a new row, not a higher count, since a long transcript keeps only its tail rendered — and otherwise is reported as failed, naming what the page did. A session not on the list is not visited and its answer is left untouched. When the worker also asked for a session to be created, that comes first, on the list page the cycle starts from and through the same steps as any creation — a run is waiting on it; then the visits; then the "New" link takes the page back to the list without a load. The worker gets back what the creation did, what each visit found, and what each delivery did. While a list read or a drive is running, a second instruction is refused as busy rather than run alongside it — two drives in one page would navigate over each other; it happens when the worker that sent the first ended mid-cycle, since the page drives on regardless. diff --git a/packages/chrome-extension/content.js b/packages/chrome-extension/content.js index afe05d73..5c0579b3 100644 --- a/packages/chrome-extension/content.js +++ b/packages/chrome-extension/content.js @@ -773,10 +773,12 @@ const LIST_LABELS = new Map([ ]) /** * A pull request's label stands in for the status only with no status word on the row: merged or - * closed, the session's work landed; still open, the session went quiet and is idle (#1707). + * closed, the session's work landed; in any other state — "Open", "Draft" — the session went + * quiet and is idle (#1707). Only landed is spelled out: a state the bridge has not seen is + * still a pull request that has not landed. */ +const PR_LABEL = /^#\d+ · / const LANDED_LABEL = /^#\d+ · (Merged|Closed)\b/ -const OPEN_LABEL = /^#\d+ · Open\b/ const NAV_WAIT_MS = 15000 const ROWS_WAIT_MS = 20000 const SEND_WAIT_MS = 15000 @@ -841,7 +843,7 @@ function statusOf(anchor) { // Only with no status word on the row does its pull request stand in for one: a row can carry // both, and "Awaiting input" beside a pull request is still a session stopped for its user. if (labels.some(label => LANDED_LABEL.test(label))) return { status: 'landed' } - if (labels.some(label => OPEN_LABEL.test(label))) return { status: 'idle' } + if (labels.some(label => PR_LABEL.test(label))) return { status: 'idle' } return { status: 'unknown', label: (labels[0] ?? '').slice(0, 80) } } diff --git a/packages/chrome-extension/driver-plan.SPEC.md b/packages/chrome-extension/driver-plan.SPEC.md index 4bb1539b..a354d3f9 100644 --- a/packages/chrome-extension/driver-plan.SPEC.md +++ b/packages/chrome-extension/driver-plan.SPEC.md @@ -6,7 +6,7 @@ Fifty cloud sessions are running. The dashboard user wants every question those ## Glossary -- **stopped** — a session claude.ai's session list shows as "Awaiting input" (it stopped to ask its user), "Unread response" (it finished a turn nobody has read) or idle (nothing is happening in it — the list's "Idle", or a row showing only an open pull request). +- **stopped** — a session claude.ai's session list shows as "Awaiting input" (it stopped to ask its user), "Unread response" (it finished a turn nobody has read) or idle (nothing is happening in it — the list's "Idle", or a row showing only a pull request that is not merged or closed). ## Business logic — TL;DR