SLOPSHOPPER

mm-bridge

Massmarket bridge: pins and names each client thread with what is due, shows the reply drafts in a side pane, updates a client's thread when something new…

newpanebandguardcommandprompt
v0.5.13no licenseupdated 2026-10-15Massmarket-AI/claude-plugins/plugins/mm-bridge
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mm-bridge
│ ┃ Needs you ✕ › fix the failing auth test and add an audit log call │ ┃ Checking… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /mm │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Needs you
Checking…
Pane · mm-view
Nothing to show yet.
README

Massmarket Claude Code plugins

The plugins every Massmarket teammate runs in Claude Code. They update themselves: once installed, each new release reaches your computer on its own (Claude Code checks for updates when a session starts, and the new version loads the next time you open it).

PluginWhat it does
mm-hooksKeeps the Massmarket worklog hook on this computer current. It only updates a hook the installer already set up; it never installs one, and never touches your token, your tracked folders or your settings.
mm-bridgeIt shows who needs you in a strip above the prompt in every thread and in a side pane (and says who holds a client's reply drafts once someone has picked them up), names and pins each client's thread, and never sends anything to a client. A client's thread is pinned while something of it is due within the next 24 business hours, and unpinned once it is clear. Each client has one chip: in its own thread when it has one (Do it here), otherwise in the thread you are working in; the strip in the other threads says where it is. When something new arrives for one of your clients, the bridge updates that client's thread for you, the same way Update thread does, except that it never picks up the reply drafts: it shows them, and a teammate can still take them. It waits until it has seen the same thing for 10 minutes, and until the reply drafts are ready or two hours have passed since it first saw the client's message. It never sends into a thread you used in the last 10 minutes, sends at most 3 such updates at a time and 12 an hour, and sends none on a computer whose time-tracking hook is older than 0.11.5. Type /mm auto off to stop this. A thread that gets long is compacted by Claude Code instead of being replaced. Starting a new thread still needs your click, because the app only starts one when you ask. When two or more clients need a new thread, one press of Start all (in the strip and the pane) starts them all, one after another and at most 8 per press, without picking up their reply drafts; typing /mm start all does the same. Open thread and Update thread take you to the thread by opening its link with your computer's own link opener. A client whose reply drafts a teammate holds is listed under "With a teammate" and is not pinned. A client whose only items are more than 3 business days overdue is listed under "Older", so nothing drops off the list without a trace. A pin you take off by hand stays off until something new arrives for that client. After you send or discard a reply draft, the list updates within seconds. A thread linked automatically stays linked once you send or close a reply through its pane or a route in that thread; anything else, type /mm link <client> in it. A thread the bridge linked by itself moves to the client's newest thread (one that opened for that client, or whose sessions worked on that client alone); a thread you linked yourself stays linked. Claude can relink a client when you ask, as long as the bridge (not you) made the old link. For the founder, a reply that waits for his OK is on his list whoever owns the client.

Install

Run the Massmarket installer from the time tracker extension. It adds this marketplace, installs both plugins and turns on auto-update. By hand, in a terminal:

claude plugin marketplace add Massmarket-AI/claude-plugins
claude plugin install mm-hooks@massmarket
claude plugin install mm-bridge@massmarket

then set "autoUpdate": true on extraKnownMarketplaces.massmarket in ~/.claude/settings.json.

Releasing

Anyone on the pms or web-builders team can release. A release runs on every teammate's computer at their next session, so treat it like a deploy: test it on your own machine first.

node scripts/release.mjs copies the worklog hook from the onboarding-wizard origin/main, bumps the version of each plugin that changed, validates, runs the bridge's tests, scans for secrets, commits and pushes. --dry-run does everything but the commit and push. A push that does not bump a version reaches nobody: the version number is the release.

main keeps its full history: force pushes and deleting main are blocked (the org's "Keep history on main" rule), so every release stays on record and any version can be restored with a normal commit.

Source 5 files
hooks/register.tsx 5147 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { MmBand, MmFolds, MmNotice, MmQueue, MmView } from '../types'
4
5import {
6  INTERNAL_PROJECTS, MAIL_LOOKBACK_HOURS, buildLists, byDue, isNewerVersion, isVersion, checkingTitle, internalProjects, cleanName, cleanTitle, clearedTitle, clientInTitle, clientMarkdown, clockText, feedClients, fastState, holdBanner, isClosingRoute, isUuid, needsLine, noteText, pinsNow,
7  threadTitle, threadTitleShort, titleFor, withHolder,
8} from './queue'
9import type { Feed, FeedThread, KnownClient, QueueItem } from './queue'
10import {
11  LABEL, PANE_SENTENCE, THREAD_ID, autoLinkFate, autoLinkFor, bandWords, buttonLabel, claimText, clientThreads, declaredOther, foldGroups, hasWaiting, isStillOpenRow, kickoffText, onlyClients,
12  newerThreadFor, openCommand, ownerOf, platformOf, recommend, rowButtons, rowDetail, rowFor, rowGroups, sharedThreads, signatureOf, staleCopySentence, startKickoff, threadLink, titleSuggestion,
13  REFRESH_FAILED, asksForPrep, autoUpdatable, autoUpdateDue, bandText, chipTitle, ownPackage, seenNow, waitingText,
14  START_ALL_MAX, startAllKickoff, startAllLabel, startAllSummary,
15} from './threads'
16import type { AppThread, Fold, Pairs, Recommendation, Replaced, Row, RowButton, RowState, FirstSeen, Sent } from './threads'
17
18// The Massmarket bridge.
19// Pinned threads are each person's to-do list: a client's thread pins itself and its name says what is
20// due and when; opening it shows the drafts in a side pane and leaves a short note for Claude.
21//
22// Rob, 2026-10-12: "There should be a process that should already be looking to see what threads exist and
23// deciding whether we should start a new one or not, and then making it really easy to pick up." So a
24// client's existing thread links itself (a thread whose Claude Code session declared that client), each row
25// of the Needs-you pane carries a recommendation, and its buttons act on it.
26//
27// Rob, 2026-10-12, on seeing it: "I think it should be 'update thread', 'open thread', or 'start thread'...
28// depending on whether you know what we should do." So a row has ONE action, chosen by its state: Start
29// (no thread, or the existing one is old or long), Update (the thread lacks what is waiting now), or Open
30// (it already has it). And: "How do we open this panel up in other threads where it's not already open?"
31// So every desktop thread carries one line above the prompt with a button that opens the list.
32//
33// ⛔ NEVER BY ITSELF. Linking, pinning and renaming are the only things a sync does. A message goes into a
34// thread only from a button the person pressed in the pane or a /mm command the person typed: updateThread
35// and claimThread, reached from `press` and from `runCommand` when it runs for the person. That message
36// starts a model turn in the client's thread: the one paid moment the bridge causes, and the person asked
37// for it. (0.5.2, R4/R5, Rob 2026-10-13) A chip is minted by itself too, in every running thread, for a
38// client that needs a new thread (`autoChips`): a chip starts nothing until the person clicks it.
39// ⛔ (0.5.11, Rob 10-15: "I think we should start updating threads automatically") ONE exception, and only while
40//    auto is on (/mm auto, the switch auto-start has had since 0.5.7): after each sync, a client on the person's own
41//    list whose linked thread lacks what is waiting gets Update's message (in words that never take the package:
42//    kickoffText's `auto`, the marker first), through Update's own send and guards, once this bridge has seen the
43//    client unchanged for AUTO_QUIET_MS and its drafts are ready (or AUTO_NO_DRAFTS_MS have passed since it first saw
44//    the mail waiting), within AUTO_UPDATES_PER_SYNC from one sync and AUTO_UPDATES_PER_HOUR and AUTO_UPDATES_PER_DAY
45//    on this computer, never into a thread the person used in the last AUTO_IDLE_MS, never from a thread into itself,
46//    and only where the time-tracking hook counts it as automatic (`autoUpdates`). It stamps nothing and claims
47//    nothing: an automatic send is nobody's act (R11).
48// ⛔ CLAUDE CANNOT DO EITHER. The model can call the `mm` tool, and the tool runs the same `runCommand`:
49//    there, and for a /mm command that did not come from the person's own prompt (a message from another
50//    session, a scheduled task, a channel, another plugin), `update` and `start` are refused before
51//    anything is read, called or written. The same for `link` in a thread that is already another
52//    client's: only the person moves a thread from one client to another.
53// ⛔ A thread is one client's. Update never sends one client's kickoff into a thread linked to another,
54//    and a thread that ran sessions for more than one client is never linked by itself, to either.
55// ⛔ A press acts on the thread its row showed, or on none. A link, an unlink record and a stamp are
56//    written only after the message went.
57// ⛔ It only touches threads linked to a client. (0.5.2) A pin means "needs you" (R1): a client's thread is
58//    pinned while something of it must be acted on within the next 24 business hours, with no cap; when the
59//    client is clear its thread is unpinned whoever pinned it (R14), once per clear (0.5.3, `mm.cleared`: a
60//    thread the person pins again after its client cleared stays pinned). Otherwise it only unpins a thread
61//    it pinned itself, and never an internal project's thread (R18).
62// ⛔ A thread the person unlinked never links itself again, and gets its own name back.
63// ⛔ A thread that linked itself gets the pin and the name, nothing more, until the person acts on it; and
64//    the link is undone once the feed stops listing that thread's session for the client, or (B3) its
65//    session declares another project too, unless the link was used for a send (R11).
66// ⛔ Where the app's thread list is not there (a terminal session, or an answer that is not a list of
67//    threads), nothing about threads is changed and there is nothing for Update to act on.
68// ⛔ A link is never dropped only because its thread is missing from the app's list (the 60 most recent):
69//    the app is asked for that thread by id, and only "no such thread" or "archived" drops it. An empty
70//    list is this thread alone (the list leaves the calling thread out) and drops nothing at all.
71// ⛔ Feed text and thread titles are data: ids must be uuids, names and titles are cleaned (./queue.ts). A
72//    200 that is not a feed is a failed call. The [mm-link] marker is the person's own prompt's alone.
73// ⛔ A client's link moves from one thread to another in two ways only: the person's /mm link, and a new
74//    thread started from the bridge's own chip for that client (Start thread, /mm start). Never otherwise.
75// ⛔ A session reads its OWN context window, at most once in five minutes, and never another thread's.
76// ⛔ The band above the prompt draws from what is stored: it makes no feed call and no app call.
77// ⛔ The bridge reads nothing of a thread: no transcript, no events. A new thread that takes over from an
78//    old one is asked, in its kickoff, to read that thread's last three messages itself.
79// ⛔ To take the person to a thread, ONE program is started: the system's own opener, with the app's own
80//    link to that thread as its only argument besides the opener's own, and only because the person
81//    pressed Open thread or Update thread or typed /mm update (openThread).
82//
83// Rob, 2026-10-13: "it takes quite a long time for anything to happen after we click a button to open the
84// list or update threads or anything." Every press waited on the feed (an HTTPS call to OW whose route reads
85// many tables) and a whole pass over the app's threads, some presses on two. So (0.5.1, S2) a press draws
86// its answer AT ONCE from what the row was drawn from: the last sync stored, the store, and the row's own
87// thread. The feed and the app are asked AFTER that, and the list is drawn again when they answer. A press
88// may take a feed this session read less than FEED_REUSE_MS ago, and a refresh that fails says so in one
89// line (L3). Update and Open ask the app one thing first: whether the row's thread is still there
90// (`storedPass`). Update refuses when the client's link, read from the store, is now another thread. And
91// Update, the one press that sends, reads the feed once before every send, drawing `Checking…` meanwhile
92// (M1): with no feed it sends nothing, with nothing waiting now it sends nothing, and otherwise its kickoff
93// is the client as the feed has it now; on a thread the bridge linked by itself, or one its row offered
94// with no link, that feed also says whether B3 has let the thread go, or a newer thread of the client's has
95// taken it (`updateThread`, `takenOver`). ⛔ Nothing that guards a send moved: a press acts on the thread its
96// row showed or on none, B3, a thread taken over and the second-send guards are asked before the send as
97// before, and every write is made over a fresh copy and checked again.
98
99const EXT = 'https://onboarding-wizard.massmarket.ai/api/ext'
100// Keep equal to .claude-plugin/plugin.json; scripts/release.mjs checks it.
101const VERSION = '0.5.13'
102/** The version that changes the person's thread list view once per machine (R15): it is recorded with the change. */
103const VIEW_VERSION = '0.5.2'
104const CLIENT_HEADER = { 'X-MM-Client': `mm-bridge/${VERSION}` }
105const VIEW = { plugin: 'mm-bridge', key: 'view' } as const
106const QUEUE = { plugin: 'mm-bridge', key: 'queue' } as const
107const NOTICE = { plugin: 'mm-bridge', key: 'notice' } as const
108const BAND = { plugin: 'mm-bridge', key: 'band' } as const
109const FOLDS = { plugin: 'mm-bridge', key: 'folds' } as const
110const PANE_VIEW = 'mm-view'
111const PANE_QUEUE = 'mm-queue'
112const SYNC_MS = 5 * 60_000
113const TICK_MS = 60_000
114/** (0.5.3) How often a thread withdraws its own chips that are no longer wanted, from the store alone (`chipsPass`). */
115const CHIPS_PASS_MS = 15_000
116const LEASE_MS = 150_000
117const MAX_WRITES = 24
118const DAY_MS = 24 * 3_600_000
119/** A session's own pair (Claude Code session id → app thread) is kept this long after it last ran. */
120const PAIR_KEEP_MS = 30 * DAY_MS
121/** The most threads the store remembers an original title, or an unlink, for. */
122const REMEMBER_MAX = 300
123/** A link the person made or used stays pinned this long, ahead of the cap. Real hours: this is not a client's clock. */
124const ACTED_MS = DAY_MS
125/** The desktop app's own record of a thread is read only when it changed in this long. */
126const RECORD_MAX_AGE_MS = 30 * DAY_MS
127/** `local_<id>.json`: one record per thread, named by the app's thread id. */
128const RECORD_FILE = /^(local_[A-Za-z0-9_-]{1,100})\.json$/
129/** The most record files opened in one sync. */
130const RECORDS_PER_SYNC = 40
131const SESSION_ID = /^[A-Za-z0-9_-]{1,100}$/
132/** A link the bridge made by itself is undone once its thread is missing from the feed's threads this many syncs in a row. */
133const AUTO_MISSES = 3
134/** Two of those misses count only when they are this far apart: three /mm in a row are one sync, not three. */
135const MISS_GAP_MS = 5 * 60_000
136/** The most threads asked for by id in one sync: linked or pinned threads the app's list (its 60 most recent) left out. */
137const LOOKUPS_PER_SYNC = 10
138/** A chip the bridge minted is remembered this long: a thread started from it later than that is a thread like any other. */
139const CHIP_KEEP_MS = 30 * DAY_MS
140/** The most chips remembered at once. */
141const CHIPS_MAX = 250
142/** The most chips one thread mints by itself, soonest due first. */
143const AUTO_CHIPS_MAX = 5
144/**
145 * (0.5.4, C1-C3) The chips a thread mints by itself from this version on carry this mark. One without it (0.5.3's
146 * copy in every running thread) is withdrawn by the thread that minted it on its first pass (C6).
147 */
148const CHIP_GEN = 4
149/** (0.5.4, C3) A thread whose tick ran no longer ago than this is running: it can hold a chip. */
150const ALIVE_MS = 5 * 60_000
151/** (0.5.4, C3) A no-thread client's chip moves to the thread the person works in once they have worked elsewhere this long. */
152const HANDOVER_MS = 10 * 60_000
153/** (0.5.7) How long a session goes by its last look at which session tools the app gives it (`sessionTools`). */
154const TOOLS_EVERY_MS = 5 * 60_000
155/** (0.5.7, Rob 10-14: start threads by itself) The most threads this machine starts by itself in an hour, and in a day. */
156const AUTO_STARTS_PER_HOUR = 4
157const AUTO_STARTS_PER_DAY = 20
158/** (0.5.7) A thread a session is about to start by itself is that session's alone this long (`mm.starting`). */
159const STARTING_MS = 2 * 60_000
160/**
161 * (0.5.11, Rob 10-15: update threads by itself) The most client threads this machine updates by itself in an hour, and in a day.
162 * (0.5.13, Rob 10-15: "12 updates an hour") The hour's was 6.
163 */
164const AUTO_UPDATES_PER_HOUR = 12
165const AUTO_UPDATES_PER_DAY = 40
166/**
167 * (0.5.13) The line saying a client was not updated because the hour's or the day's cap is reached is written to the
168 * notice log at most once per client in this long, across every running thread (`capNotice`, `mm.capLogged`).
169 */
170const CAP_LOG_EVERY_MS = 3_600_000
171/** (0.5.11) An update a session is about to send by itself is that session's alone this long (`mm.updating`). */
172const UPDATING_MS = 2 * 60_000
173/** (0.5.11, review of PR #15, F1) The most updates sent by itself from one sync: the rest wait for the next sync. */
174const AUTO_UPDATES_PER_SYNC = 3
175/** (0.5.11, review of PR #15, F5) A thread the person used this recently is not updated by itself: the next sync tries again. */
176const AUTO_IDLE_MS = 10 * 60_000
177/** (0.5.11, review of PR #15, F4) A session reads what the time-tracking hook is no more often than this. */
178const HOOK_CHECK_MS = 10 * 60_000
179/** (0.5.7) A band note (a thread started by itself, the hourly cap) shows in every thread this long. */
180const NOTE_SHOW_MS = 15 * 60_000
181/**
182 * (0.5.7) What start_session's `background` carries, which a "fresh" session requires (at least 80 characters, the
183 * app's own schema): the brief itself is the client's kickoff, unchanged.
184 */
185const START_BACKGROUND =
186  'Opened by the Massmarket bridge (mm-bridge) for one client on the Needs-you list. The brief is the kickoff the bridge sends that client\'s thread; its [mm-link] marker links this thread to the client.'
187/** R10: how long a press, or a chip minted by itself, waits for the app before it says so. */
188const APP_WAIT_MS = 15_000
189/** R10: after the app did not answer a chip, or refused one, this thread mints none by itself for this long (or until its next turn). */
190const MINT_PAUSE_MS = 10 * 60_000
191/** (R9) A sync on record older than this is not a read the band may call all clear from. */
192const STALE_SYNC_MS = 3 * SYNC_MS
193/** A chip whose withdrawal fails this many times is forgotten. */
194const DISMISS_TRIES = 5
195/** A press on a row's button is in flight no longer than this: after it, the row takes a press again. */
196const PRESS_HOLD_MS = 30_000
197/** The system's opener is given this long to take a thread's link. */
198const OPEN_WAIT_MS = 10_000
199/**
200 * After a route call that can close something in this thread (R6), one sync runs this long after the last
201 * such call: at once (Rob, 2026-10-13), and calls made within the same second still make one sync.
202 */
203const ASK_WAIT_MS = 1_000
204/** A feed this session read may be taken for a press's refresh while it is younger than this (S2). */
205const FEED_REUSE_MS = 60_000
206/** A pickup view this session read is drawn at once, while it is read again, when it is younger than this. */
207const PICKUP_REUSE_MS = 60_000
208/** The most pickup views remembered. */
209const PICKUPS_MAX = 20
210/** What the pane says while it has nothing to draw yet: the list, or a client's pickup view. */
211const CHECKING = 'Checking…'
212
213/**
214 * `[mm-link <project id>]`, counted only at the very start of a prompt: the message itself, or (0.5.8) the task
215 * the app's wrapper carries (`promptOf`). Never anywhere else in the text.
216 */
217const MARKER = /^\[mm-link ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\]/
218/**
219 * (0.5.8) How the app hands a thread its first message when another session started it (read from the app's own
220 * code, 10-14; the hook builder saw the first form in a real thread):
221 * - a fresh thread, from start_session or from a card the person started:
222 *   `Background (from the session that started this one):\n<background>\n\nTask:\n<prompt>`;
223 * - a fork: `Task (from the session that started this one):\n<prompt>`; an offer's brief has the fresh form inside that;
224 * - after the prompt, its notes line: `\n\nShared notes folder for this group of sessions (…): <path>`.
225 * Each wrapper counts only where the message begins.
226 */
227const WRAP_TASK = /^Task \([^)\n]{1,200}\):\n/
228const WRAP_BACKGROUND = /^Background \([^)\n]{1,200}\):\n[\s\S]*?\n\nTask:\n/
229const WRAP_NOTES = /\n\nShared notes folder for this group of sessions \([^\n]*$/
230/**
231 * (0.5.9) The app's own notes, put in front of a message it sends: each `<system-reminder>\n…\n</system-reminder>`,
232 * one per line, then a blank line, then the message (read from the app's own code, 10-15). A thread started while
233 * the session that started it already has linked sessions opens with one, `Linked sessions now (status, not an
234 * instruction): …`. On 10-15 a dozen threads started together: the first opened without it and its marker counted;
235 * every later one opened with it, before the wrapper, and none of their markers did.
236 */
237const NOTE_OPEN = '<system-reminder>\n'
238const NOTE_CLOSE = '</system-reminder>'
239/** (0.5.10) A note's close tag at the start of a line. */
240const LINE_CLOSE = /(?:^|\n)<\/system-reminder>/
241/** The most app notes taken off the front of one message. */
242const APP_NOTES_MAX = 10
243/**
244 * (0.5.9) The message after the app's notes in front of it. ⛔ Fails closed (review of 54a0437d, L4): each note runs
245 * from `<system-reminder>\n` to the FIRST `</system-reminder>`, which must begin a line and be followed by a blank
246 * line (then the message) or at once by the next note. Anything else (a close tag inside a line, a second open tag
247 * inside the note, CRLF, no close tag, more than APP_NOTES_MAX notes) gives the message back as it is: it begins
248 * with `<system-reminder>`, so neither the wrapper nor a marker is read in it.
249 * ⛔ (0.5.10, review of 0.5.9, finding 2) So does a message that, after its notes, still has a `</system-reminder>`
250 * at the start of a line: what looked like the message may be the rest of a note (probe D: a marker inside a
251 * note's body, after a line-start close tag and a blank line).
252 */
253function afterAppNotes(text: string): string {
254  let rest = text
255  for (let n = 0; n < APP_NOTES_MAX && rest.startsWith(NOTE_OPEN); n++) {
256    const close = rest.indexOf(NOTE_CLOSE)
257    if (close < 0 || rest[close - 1] !== '\n') return text
258    if (rest.slice(NOTE_OPEN.length, close).includes('<system-reminder>')) return text
259    const after = rest.slice(close + NOTE_CLOSE.length)
260    if (after.startsWith('\n\n')) return LINE_CLOSE.test(after.slice(2)) ? text : after.slice(2)
261    if (!after.startsWith(`\n${NOTE_OPEN}`)) return text
262    rest = after.slice(1)
263  }
264  return text
265}
266/**
267 * (0.5.8) The prompt a message carries: the message itself, or, when it begins with the app's wrapper, the task
268 * after it, its notes line taken off. `task`: it began `Task (…):`, the form the app sends as `auto-continuation`.
269 * (0.5.9) The app's own notes in front of it are taken off first: the message begins after them (`afterAppNotes`).
270 */
271function promptOf(text: string): { prompt: string; task: boolean } {
272  const body = afterAppNotes(text)
273  const task = WRAP_TASK.exec(body)
274  let rest = task ? body.slice(task[0].length) : body
275  const background = WRAP_BACKGROUND.exec(rest)
276  if (background) rest = rest.slice(background[0].length)
277  if (!task && !background) return { prompt: body, task: false }
278  return { prompt: rest.replace(WRAP_NOTES, ''), task: task !== null }
279}
280/**
281 * Where a prompt or a /mm command is the person's own: typed here (`composer`), sent from their phone or
282 * the web (`bridge`), or the app's own turn for them, as when it starts a thread from a chip (`sdk`).
283 * ⛔ Nothing else: not another Claude session's message (`peer`), a schedule, a channel or a plugin.
284 */
285const PERSON_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk'])
286/**
287 * (0.5.8) The app's own delivery of a started thread's first task, for a fork or an offer's brief. It counts for
288 * the marker only in the app's `Task (…):` wrapper (`promptOf`), and for nothing else.
289 */
290const STARTED_TASK_ORIGIN = 'auto-continuation'
291
292const NO_THREAD_LIST = 'The app\'s thread list is not available here.'
293/** What `link` from anything but the person says of a client the person linked to another thread (F4). */
294const alreadyHas = (client: string) => `${client} already has a thread. Only the person can move it: they type /mm link ${client} in the thread that should have it.`
295/** R10: what a press that makes a chip says before it asks the app for anything. */
296const MAKING_CHIP = (client: string) => `Making a chip for ${client}…`
297/** (0.5.7) The same, where Start opens the thread itself (start_session). */
298const STARTING_THREAD = (client: string) => `Starting ${client}'s thread…`
299/** R10: what it says when the app does not answer that in APP_WAIT_MS. */
300const NO_ANSWER = (client: string) => `The app did not answer in 15 seconds, so no chip was made. Type /mm start ${client} in any thread.`
301/** (fix round 1, L10) What a second message to a client says while the first is still going out. */
302const STILL_GOING = (client: string) => `A message to ${client}'s thread is still going out. Check that thread before pressing again.`
303/** R10 (Opus, 10-13, decision 1): what Update or Claim says when the app has not confirmed its message in APP_WAIT_MS. */
304const NOT_CONFIRMED = (client: string) => `The app has not confirmed the message to ${client}'s thread yet. Check that thread before pressing again.`
305/** R15: said once in the pane, the time the thread list's view is changed on this machine. */
306const VIEW_NOTICE = 'Your thread list is now grouped by date, newest activity first, with pinned threads on top. Change it any time from the sidebar\'s filter menu.'
307const THREAD_GONE = 'That thread is no longer available. The list has been refreshed.'
308/** What Claude is told when it calls the tool with a verb only the person may use. */
309const ONLY_THE_PERSON = {
310  update: 'Only the person can do this: press Update thread in the pane or type /mm update <client>.',
311  start: 'Only the person can do this: press Start thread in the pane or type /mm start <client>.',
312} as const
313
314type Mcp = { content?: Array<{ type: string; text?: string }>; isError?: boolean }
315/** `internal` (0.5.2, R18): the pin is on an internal project's thread, which the bridge never unpins. */
316type Pin = { projectId: string; client: string; byUs: boolean; internal?: true }
317type Last = {
318  at: number
319  member: string
320  /** The clients that need the person now: what pins, and what the header and the band count. */
321  items: QueueItem[]
322  /** Clients whose reply drafts a teammate holds, and clients whose only items are more than 3 business days overdue: listed, never pinned, never counted. */
323  teammate?: QueueItem[]
324  older?: QueueItem[]
325  /** (0.5.2, R8) Clients whose package waits for the server's check of our newer send: off the list, not pinned, not counted. */
326  checking?: QueueItem[]
327  pinned: number
328  recs: Record<string, Recommendation>
329  /** Every project id the last feed carried: the only ids an [mm-link] marker may name. */
330  projects?: string[]
331  error?: string
332}
333/** Every client the last sync listed, in any of its lists: what a button, a command or a thread's own pane may be about. */
334const listedIn = (last: Last | null | undefined): QueueItem[] => [
335  ...(Array.isArray(last?.items) ? last.items : []),
336  ...(Array.isArray(last?.teammate) ? last.teammate : []),
337  ...(Array.isArray(last?.older) ? last.older : []),
338  ...(Array.isArray(last?.checking) ? last.checking : []),
339]
340/**
341 * A pin the person took off a thread the bridge had pinned: when, and the newest time of each kind of
342 * thing its client had then. The thread is not pinned again until one of them is newer.
343 */
344type Unpinned = { at: number; mail: number; pkg: number; task: number }
345/**
346 * (0.5.3) The person went on in the client's linked thread while a chip for a new thread (R3, or R8's long-thread
347 * one) was waiting: in which thread, when, and the newest time of each kind of thing the client had then. Until
348 * something newer than both comes for the client, or its link moves, those chips are spent and none is minted.
349 */
350type ClaimedHere = { sid: string; at: number; newest?: { mail: number; pkg: number; task: number } }
351
352/**
353 * (0.5.3) Whether a claim in place still holds: the client is still linked to the thread it was made in, and
354 * nothing newer has come for the client since (each kind measured against the later of the claim and what the
355 * client had then: a task's time can be its due time, still ahead).
356 */
357function claimHolds(claim: ClaimedHere | undefined, projectId: string, links: Readonly<Record<string, string>>, last: Last | null): claim is ClaimedHere {
358  if (!claim || typeof claim !== 'object' || typeof claim.sid !== 'string' || !Number.isFinite(claim.at)) return false
359  if (links[projectId] !== claim.sid) return false
360  const it = listedIn(last).find(i => i.projectId === projectId)
361  if (!it) return true
362  for (const kind of ['mail', 'pkg', 'task'] as const) {
363    if ((Number(it.newest?.[kind]) || 0) > Math.max(Number(claim.newest?.[kind]) || 0, claim.at)) return false
364  }
365  return true
366}
367
368/**
369 * (0.5.3) A chip for a new thread that replaces the client's thread `sid`: R3's (minted while the client had it), or
370 * R8's long-thread one. Never R4's, for a client with no thread, and (0.5.4) never a `Do it here` chip.
371 */
372const replacesThread = (c: Chip, sid: string): boolean => c.need !== 'here' && (c.had === sid || c.need === 'long')
373/**
374 * (0.5.4, C3) What each running thread last said of itself: when its tick last ran, and when the person last wrote
375 * in it (their own prompt). The thread the person is working in now holds a no-thread client's chip.
376 */
377type Seen = Record<string, { tick: number; human?: number }>
378type Verb = RowButton
379/** A link the person made or used: which thread, and when. */
380type Acted = Record<string, { sid: string; at: number }>
381/**
382 * A chip the bridge minted for a client and the person has not started yet. A chip shows only in the
383 * thread that minted it, and only that thread's session can withdraw it, so the record says which.
384 * One live chip per client: minting another marks the earlier one `superseded`.
385 */
386type Chip = {
387  projectId: string
388  /** What its prompt carries (signatureOf): the thread started from it already has this. */
389  signature: string
390  /** The app's id for the chip, from its answer to the mint; '' when it gave none (it cannot be withdrawn then). */
391  taskId: string
392  /** The thread it was minted in. */
393  threadId: string
394  at: number
395  /** Its prompt exactly as minted: the thread that opens with this prompt was started from this chip. */
396  prompt: string
397  /** The thread the client was linked to when it was minted, if any: a link to any other thread since means the client has moved on. */
398  had?: string
399  superseded?: true
400  /** Withdrawals that failed so far. */
401  tries?: number
402  /** (0.5.2, R5) Minted by the thread itself, not from a press: withdrawn once the need it was minted for is gone. */
403  auto?: true
404  /**
405   * (0.5.2) What it was minted for: a new thread (`start`, R3/R4), or a new thread for a client still open after our
406   * reply whose thread is long (`long`, R8); (0.5.4, C1) or the client's kickoff run in its own thread (`here`).
407   */
408  need?: 'start' | 'long' | 'here'
409  /** (0.5.4) CHIP_GEN on a chip this version minted by itself. */
410  gen?: number
411  /** (0.5.4, C2/C3) The thread it is in, as the band names it (`threadTitleShort`), from that thread's title when it was minted. */
412  place?: string
413}
414/**
415 * A link the bridge made by itself and the person has not acted on: its thread, how many syncs in a row
416 * the feed has not listed it, and when the last of those was counted.
417 */
418type Auto = Record<string, { sid: string; miss: number; at?: number; made?: number }>
419/** What the app's record of a thread said when it was opened: its modified time then, and the session id if it gave one. */
420type Records = Record<string, { m: number; cli?: string }>
421/**
422 * The app's threads as one sync sees them. `complete` is false for an empty list (the app's list leaves the
423 * calling thread out, so a person with one open thread gets none): this thread is then the whole list, and
424 * ⛔ nothing may be dropped on its word.
425 */
426type Listed = { threads: AppThread[]; complete: boolean }
427/**
428 * One pass over a feed. `listed` is false where the app's thread list could not be had. `unseen` holds the
429 * linked or pinned threads the list left out and the app did not answer for: kept, and not acted on.
430 * `stored`: no pass was made. A press's answer drawn from what the last sync stored, `live` holding at most
431 * the row's own thread as the app answered for it by id (`storedPass`, S2).
432 */
433type Pass = { last: Last; live: Map<string, AppThread>; pairs: Pairs; listed: boolean; unseen: ReadonlySet<string>; stored?: true }
434/** Who ran a /mm command: the person, or anything else (the model through the tool, another session, a schedule, a plugin). */
435type By = 'person' | 'other'
436
437/**
438 * This session's thread, as the app answered `get_session self`, by the session's own id. In the app one
439 * module serves one session, so this holds one entry; keyed by the session, several sessions run against
440 * one store in a test (concurrency.test.ts) each know their own thread.
441 */
442const selves = new Map<string, string>()
443/**
444 * The presses in flight in this session, by client: when each began. A second press for a client whose
445 * first has not returned does nothing (Rob pressed Update, saw nothing happen, pressed again, and the
446 * thread got two messages).
447 */
448const pressing = new Map<string, number>()
449let pressCount = 0
450/** The one sync this session owes after a reply draft was sent or discarded in it (askSync): its timer, or none. */
451let askTimer: { cancel: () => void } | null = null
452/**
453 * What this session's Update sent into a thread, by client and thread: the kickoff's signature. Asked before
454 * every Update beside what is stored, so a second press never sends the same kickoff into the same thread,
455 * even when the store refused every write after the first went (review of 79cbc596, F2).
456 */
457const sentHere = new Map<string, string>()
458/**
459 * The chips this session minted whose record the store refused, by client, thread and what they carry: a
460 * second press mints none while the store cannot say the chip is there. Forgotten once the record is written,
461 * or the client has moved on (M1).
462 */
463const mintedHere = new Set<string>()
464/** The chip mints in flight in this session (thread, client, signature): a second is not started while one waits for the app. */
465const minting = new Set<string>()
466/**
467 * Fix round 1, F4: every chip this session minted, by thread, client, id and time (`chipKey`), as this session
468 * knows it, whatever the store lost to another session's write: a record missing from the store is written back
469 * (the 1 to 3 second check, and every tick), never minted again, and still withdrawn. Forgotten once withdrawn.
470 */
471const ownChips = new Map<string, Chip>()
472const chipKey = (c: Chip): string => `${c.threadId}\n${c.projectId}\n${c.taskId}\n${c.at}`
473/**
474 * R10, by session: no chip is minted by itself before `until` (the app did not answer one, or refused it), and
475 * `atTurn`, the next turn in the thread tries again (the app did not answer one outside a turn). (Keyed by the
476 * session as `selves` is: several sessions run against one module in a test.)
477 */
478const mintPause = new Map<string, { until: number; atTurn: boolean }>()
479/** R15: the sessions that tried the view change already. */
480const viewTried = new Set<string>()
481/** The clients whose message (Update, Claim) is in flight in this session: their press stays held until the app answers it. */
482const sending = new Set<string>()
483/**
484 * The feed this session read last: when, and the sync record the pass over it stored (`wrote`, as stored). A
485 * press takes it again only while it is younger than FEED_REUSE_MS AND the store still holds that very
486 * record: no other session has synced since, so the list drawn from it is the list drawn now (S2).
487 */
488let recent: { at: number; feed: Feed; wrote: string } | null = null
489/** The pickup views this session read, by package: drawn at once for a press while they are read again. */
490const pickups = new Map<string, { at: number; text: string }>()
491/** Each session's newest draw of its client part: an older one still reading its view draws nothing over it. */
492const viewDraws = new Map<string, number>()
493
494/**
495 * A write that failed after the person's act went (an Update's message, a chip): noted where the bridge's
496 * other errors go, the heartbeat file the timer's and registration's errors go to. Never swallowed silently.
497 */
498async function noteFailure($: EngineInterface, what: string, err: unknown): Promise<void> {
499  await beat($, { ok: false, writeError: short(`${what}: ${errorText(err)}`, 300) })
500}
501
502const textOf = (r: Mcp) => (r.content ?? []).filter(c => c.type === 'text').map(c => c.text ?? '').join('\n')
503const short = (s: string, n = 200) => s.replace(/\s+/g, ' ').slice(0, n)
504const errorText = (err: unknown) => short(err instanceof Error ? err.message : String(err), 300)
505/** The member's address as the feed gave it, kept to what an address is made of. */
506const memberText = (member: unknown) => (typeof member === 'string' ? member : '').replace(/[^A-Za-z0-9._%+@-]/g, '').slice(0, 120)
507/** A thread name the bridge wrote itself: never anybody's original title. */
508const isOurTitle = (title: string) => /^(?:🔴|🟡|✓)\s/u.test(title)
509
510async function mcpJson($: EngineInterface, server: string, tool: string, args: Record<string, unknown>): Promise<unknown> {
511  const r = (await $.mcp.call(server, tool, args)) as Mcp
512  const text = textOf(r)
513  if (r.isError) throw new Error(`${server}.${tool}: ${short(text)}`)
514  try {
515    return JSON.parse(text.slice(text.search(/[[{]/)))
516  } catch {
517    return text
518  }
519}
520
521/**
522 * R10 (Rob, 2026-10-13: Start on a client said nothing at all, and no chip came): an app call given at most `ms`
523 * to answer. `{ ok: false }` when it has not by then. The call itself goes on (nothing on `$` stops it): a caller
524 * that must know how it ended takes `work` itself later. A call that fails before then rejects as it would have.
525 */
526async function within<T>($: EngineInterface, work: Promise<T>, ms = APP_WAIT_MS): Promise<{ ok: true; value: T } | { ok: false }> {
527  const timer: { cancel?: () => void } = {}
528  const timeout = new Promise<{ ok: false }>(resolve => {
529    try {
530      const t = $.clock.after(ms, () => resolve({ ok: false }))
531      timer.cancel = () => t.cancel()
532    } catch {
533      // no timer here: the call is waited for, as in 0.5.1
534    }
535  })
536  const answered = work.then(value => ({ ok: true as const, value }))
537  // a call that fails after the wait ended has nobody left to tell
538  answered.catch(() => {})
539  try {
540    return await Promise.race([answered, timeout])
541  } finally {
542    timer.cancel?.()
543  }
544}
545
546/** mcpJson given at most APP_WAIT_MS: what the app answered, or `null` when it did not answer in time. */
547async function mcpJsonWithin($: EngineInterface, server: string, tool: string, args: Record<string, unknown>): Promise<unknown> {
548  const r = await within($, mcpJson($, server, tool, args))
549  return r.ok ? r.value : null
550}
551
552async function home($: EngineInterface): Promise<string> {
553  return (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
554}
555
556// ---------------------------------------------------------------- (0.5.5) a newer mod installed on this machine
557
558/** How often a thread looks for a newer mod. */
559const VERSION_EVERY_MS = 5 * 60_000
560/** The newest mm-bridge on this machine, by session, when it is later than this thread's own VERSION ('' otherwise), and when that was looked for. */
561const newerMod = new Map<string, { version: string; at: number }>()
562
563/**
564 * (0.5.5, Rob 10-14: the band "could also say when there's an update and they need to restart their Claude Code")
565 * Whether a later mm-bridge than the one this thread runs (VERSION) is installed on this machine. Two ways, both
566 * read, the later version wins:
567 *   (a) the plugin cache: the version folders beside this plugin's own (`$.plugin.root`, the engine's word for the
568 *       directory holding plugin.json, whose parent holds one folder per installed version), listed with `$.fs.list`;
569 *       else `~/.claude/plugins/cache/massmarket/mm-bridge`;
570 *   (b) the store: each running thread writes the version it runs (`mm.versions`); any reported in the last day.
571 * At most once in VERSION_EVERY_MS for a session. Nothing is called on the app.
572 */
573async function checkVersions($: EngineInterface): Promise<void> {
574  const now = await $.clock.now()
575  const id = await instanceId($)
576  const was = newerMod.get(id)
577  if (was && now - was.at < VERSION_EVERY_MS) return
578  const me = await knownSelf($)
579  const versions = await storeGet<Record<string, { version?: unknown; at?: unknown }>>($, 'mm.versions', {})
580  if (me && (versions[me]?.version !== VERSION || !(now - Number(versions[me]?.at) < DAY_MS / 24))) {
581    await patchStore<Record<string, { version?: unknown; at?: unknown }>>($, 'mm.versions', {}, fresh => {
582      fresh[me] = { version: VERSION, at: now }
583      for (const [sid, v] of Object.entries(fresh)) if (!v || !(now - Number(v.at) <= DAY_MS)) delete fresh[sid]
584    }).catch(() => {})
585  }
586  let newest = VERSION
587  for (const v of await installedVersions($)) if (isNewerVersion(v, newest)) newest = v
588  for (const v of Object.values(versions)) if (v && now - Number(v.at) <= DAY_MS && isNewerVersion(v.version, newest)) newest = String(v.version)
589  newerMod.set(id, { version: newest === VERSION ? '' : newest, at: now })
590}
591
592/** (0.5.5, way (a)) The versions of mm-bridge in the plugin cache: the folder names that are versions. [] when it cannot be listed. */
593async function installedVersions($: EngineInterface): Promise<string[]> {
594  const dirs: string[] = []
595  try {
596    const root = String($.plugin.root ?? '')
597    const last = root.split(/[\\/]/).pop() ?? ''
598    if (isVersion(last)) dirs.push(root.slice(0, root.length - last.length - 1))
599  } catch {
600    // no word on this plugin's own folder: the usual place below
601  }
602  const h = await home($)
603  if (h) dirs.push(`${h}/.claude/plugins/cache/massmarket/mm-bridge`)
604  for (const dir of dirs) {
605    try {
606      return (await $.fs.list(dir)).filter(e => e.kind === 'dir' && isVersion(e.name)).map(e => e.name)
607    } catch {
608      // the next place
609    }
610  }
611  return []
612}
613
614async function token($: EngineInterface): Promise<string> {
615  try {
616    const t = (await $.fs.read(`${await home($)}/.claude/mm-hooks/token`)).trim()
617    if (t) return t
618  } catch {
619    // no token file: handled below
620  }
621  throw new Error('not set up on this computer: no tracker token. Run the Massmarket installer from the time tracker extension.')
622}
623
624// The desktop app's session tools (list, rename, pin) exist only in its Code tab. Elsewhere (a terminal
625// session) the bridge still answers /mm, it just has no threads to pin or rename.
626async function desktopOnly<T>(work: () => Promise<T>, fallback: T): Promise<T> {
627  try {
628    return await work()
629  } catch {
630    return fallback
631  }
632}
633
634async function selfId($: EngineInterface): Promise<string> {
635  const cli = await instanceId($)
636  const known = selves.get(cli)
637  if (known) return known
638  // (R10: an app that does not answer in time leaves this thread unknown for now, as no app at all does)
639  const me = await desktopOnly(async () => (await mcpJsonWithin($, 'ccd_session_mgmt', 'get_session', { session_id: 'self' })) as AppThread | null, null)
640  if (!me?.sessionId) return ''
641  selves.set(cli, me.sessionId)
642  return me.sessionId
643}
644
645/** This thread's id as the timer already learnt it, or '': no app call is made to find it out. */
646async function knownSelf($: EngineInterface): Promise<string> {
647  return selves.get(await instanceId($)) ?? ''
648}
649
650async function instanceId($: EngineInterface): Promise<string> {
651  return String((await $.session.id()) ?? '')
652}
653
654/** A thread as the app answered it, kept to the fields the bridge uses; null for anything that is not an open thread. */
655function asThread(raw: unknown): AppThread | null {
656  const t = raw as Partial<AppThread> | null | undefined
657  if (!t || typeof t !== 'object' || typeof t.sessionId !== 'string' || !t.sessionId || t.isArchived) return null
658  // the app's own link to the thread, kept only when it is exactly that thread's (threadLink)
659  const link = threadLink(t as { sessionId: string; link?: unknown })
660  return {
661    sessionId: t.sessionId,
662    title: typeof t.title === 'string' ? t.title : '',
663    pinned: Boolean(t.pinned),
664    isArchived: false,
665    ...(typeof t.lastActivityAt === 'string' ? { lastActivityAt: t.lastActivityAt } : {}),
666    ...(link ? { link } : {}),
667  }
668}
669
670/**
671 * The app's open threads, or null where the list cannot be had: no app to ask (a terminal session), a call
672 * that failed, or an answer that is not a list. ⛔ null is "unknown", never "none".
673 *
674 * The app's list leaves the calling thread out, so a person with one open thread gets an empty list. An
675 * empty list from a call that worked, in a session that knows its own thread, is a list of one: this
676 * thread may link, pin and name itself. It is marked not `complete`: ⛔ an empty answer never drops a
677 * link, a pin, a title or a record, because it cannot be told from the app answering nothing by mistake.
678 */
679async function sessions($: EngineInterface): Promise<Listed | null> {
680  return desktopOnly<Listed | null>(async () => {
681    // (R10: an app that does not answer in time is no list: unknown, never none)
682    const others = await mcpJsonWithin($, 'ccd_session_mgmt', 'list_sessions', { limit: 60 })
683    if (!Array.isArray(others)) return null
684    const me = asThread(await mcpJsonWithin($, 'ccd_session_mgmt', 'get_session', { session_id: 'self' }))
685    if (others.length === 0) return me ? { threads: [me], complete: false } : null
686    const threads: AppThread[] = []
687    for (const raw of [me, ...others]) {
688      const t = asThread(raw)
689      if (t && !threads.some(x => x.sessionId === t.sessionId)) threads.push(t)
690    }
691    return { threads, complete: true }
692  }, null)
693}
694
695/**
696 * One thread, asked for by id: for a linked or pinned thread the app's list left out (the list holds its 60
697 * most recent). The thread when it is open; 'gone' ONLY when the app says there is no such thread or shows
698 * it archived; 'unknown' for any error, any other answer, or no app to ask. ⛔ Only 'gone' may drop a link.
699 */
700async function lookup($: EngineInterface, sid: string): Promise<AppThread | 'gone' | 'unknown'> {
701  const noSuchThread = (text: string) => text.includes(sid) && /\bnot found\b/i.test(text)
702  try {
703    // (R10: no answer in time says nothing about the thread)
704    const asked = await within($, $.mcp.call('ccd_session_mgmt', 'get_session', { session_id: sid }) as Promise<Mcp>)
705    if (!asked.ok) return 'unknown'
706    const r = asked.value
707    const text = textOf(r)
708    if (r.isError) return noSuchThread(text) ? 'gone' : 'unknown'
709    let raw: Partial<AppThread> | null
710    try {
711      raw = JSON.parse(text.slice(text.search(/[[{]/))) as Partial<AppThread> | null
712    } catch {
713      return 'unknown' // an answer that is not a thread says nothing about this one
714    }
715    if (!raw || typeof raw !== 'object' || raw.sessionId !== sid) return 'unknown'
716    return raw.isArchived ? 'gone' : (asThread(raw) ?? 'unknown')
717  } catch (err) {
718    // the call itself was refused: "no such thread" only when that is what the app said, about this thread
719    return noSuchThread(errorText(err)) ? 'gone' : 'unknown'
720  }
721}
722
723/** Every project id a feed carries, uuids only: what an [mm-link] marker may name. */
724function feedProjects(feed: Feed): string[] {
725  const ids = new Set<string>()
726  const add = (id: unknown) => {
727    if (isUuid(id) && ids.size < 500) ids.add(id)
728  }
729  for (const m of feed.mail ?? []) add(m.project_id)
730  for (const d of feed.drafts ?? []) add(d.project_id)
731  // (fix round 1, F5) `start_now` too
732  for (const k of [feed.tickets?.overdue, feed.tickets?.due_today, feed.tickets?.due_soon, feed.tickets?.start_now]) for (const t of k ?? []) add(t.project_id)
733  for (const t of Array.isArray(feed.threads) ? feed.threads : []) add(t?.project_id)
734  return [...ids]
735}
736
737/** The feed's `threads` rows that are usable: a uuid project and a session id the worklog hook could have sent. */
738function feedThreadsOf(feed: Feed): FeedThread[] {
739  return (Array.isArray(feed.threads) ? feed.threads : []).filter(t => t && isUuid(t.project_id) && typeof t.session_id === 'string' && SESSION_ID.test(t.session_id))
740}
741
742async function fetchFeed($: EngineInterface): Promise<Feed> {
743  // (OW's most: mail old enough to go under Older is in the feed, MAIL_LOOKBACK_HOURS)
744  const r = await $.http.fetch(`${EXT}/closeout?mail_hours=${MAIL_LOOKBACK_HOURS}`, { headers: { Authorization: `Bearer ${await token($)}`, ...CLIENT_HEADER } })
745  const failed = () => new Error(`closeout HTTP ${r.status}: ${short(r.text, 160)}`)
746  if (!r.ok) throw failed()
747  const feed = JSON.parse(r.text) as Feed | null
748  // ⛔ A 200 that is not a feed (neither `tickets` nor `mail` in it) is a failed call, not "nobody needs
749  // you": read as an empty feed it would take every pin off and rename every thread "clear".
750  if (!feed || typeof feed !== 'object' || !('tickets' in feed || 'mail' in feed)) throw failed()
751  recent = { at: await $.clock.now(), feed, wrote: '' }
752  return feed
753}
754
755/**
756 * The feed for a press's refresh (S2): the one this session read last, while it is younger than
757 * FEED_REUSE_MS and the store still holds the sync record made from it; else OW is asked again.
758 */
759async function recentFeed($: EngineInterface): Promise<Feed> {
760  const r = recent
761  if (r?.wrote && (await $.clock.now()) - r.at < FEED_REUSE_MS && JSON.stringify(await storeGet<Last | null>($, 'mm.last', null)) === r.wrote) return r.feed
762  return fetchFeed($)
763}
764
765async function storeGet<T>($: EngineInterface, key: string, fallback: T): Promise<T> {
766  const v = await $.store.get(key)
767  return (v === undefined || v === null ? fallback : v) as T
768}
769
770/** Keeps the newest `max` entries of a record the store holds (insertion order). */
771function capped<T>(record: Record<string, T>, max = REMEMBER_MAX): Record<string, T> {
772  const keys = Object.keys(record)
773  for (const k of keys.slice(0, Math.max(0, keys.length - max))) delete record[k]
774  return record
775}
776
777/**
778 * ⛔ THE ONE WAY a map or a list that sessions share is written to the store (B1, 2026-10-13: of fifteen
779 * threads started within a minute or two, two lost the link their [mm-link] marker wrote).
780 *
781 * Every desktop thread is a process of its own. The engine reads the store's file afresh on each get and
782 * writes ONE key on each set, under a lock (its own source, 2.1.295), so a set never touches another key.
783 * But a key is written whole: a session that writes back a map it read earlier takes away whatever another
784 * session put into it since (a dozen threads linking themselves; a sync whose pass takes seconds of app
785 * calls between its reads and its writes). So the key is read again immediately before the write, `change`
786 * makes ONLY its caller's own change to that fresh copy, and it is written at once: nothing is awaited
787 * between the read and the write. `change` answers false to write nothing; `stored` tells it whether the
788 * store held the key at all. A value of the wrong shape (an older version's, a damaged file) is read as
789 * `empty`.
790 * The store has no compare-and-set, so two sessions can still meet between that read and that write; what
791 * the person's own act wrote is checked again a moment later (`settleLater`).
792 */
793async function patchStore<T extends object>($: EngineInterface, key: string, empty: T, change: (fresh: T, stored: boolean) => boolean | void): Promise<T> {
794  const v = await $.store.get(key)
795  const shaped = v !== null && typeof v === 'object' && Array.isArray(v) === Array.isArray(empty)
796  const fresh = (shaped ? v : empty) as T
797  if (change(fresh, v !== undefined) === false) return fresh
798  await $.store.set(key, fresh)
799  return fresh
800}
801
802const sameValue = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b)
803/** A copy of what a pass read, to tell later what the pass itself changed. */
804const copyOf = <T,>(v: T): T => JSON.parse(JSON.stringify(v ?? null)) ?? v
805
806/**
807 * What a pass changed in a map, entry by entry, made on the store's fresh copy (`patchStore`): `before` is
808 * the map as the pass read it, `after` as the pass left it. An entry the pass did not change is left as the
809 * store has it now, and so is one another session changed since the pass read it: a sync never takes back a
810 * link, a pin or a record written while it ran (it sees it on its next pass). Answers whether it changed
811 * anything. `only` makes just the entries it set (`set`) or just the ones it removed (`delete`): a record
812 * another depends on is written before it and removed after it (F2).
813 */
814function applyChanges<V>(fresh: Record<string, V>, before: Readonly<Record<string, V>>, after: Readonly<Record<string, V>>, only?: 'set' | 'delete'): boolean {
815  let changed = false
816  for (const k of new Set([...Object.keys(before), ...Object.keys(after)])) {
817    const had = Object.hasOwn(before, k) ? before[k] : undefined
818    const has = Object.hasOwn(after, k) ? after[k] : undefined
819    if (sameValue(had, has)) continue
820    if ((only === 'set' && has === undefined) || (only === 'delete' && has !== undefined)) continue
821    const now = Object.hasOwn(fresh, k) ? fresh[k] : undefined
822    if (!sameValue(now, had)) continue
823    if (has === undefined) delete fresh[k]
824    else fresh[k] = has as V
825    changed = true
826  }
827  return changed
828}
829
830/**
831 * (0.5.9, review of 54a0437d, L2) `applyChanges` for the links, where ⛔ a thread is one client's: a client this pass
832 * links to a thread that another client holds in the store's fresh copy (another session linked it while this pass
833 * ran) keeps what the store has for it.
834 */
835function applyLinkChanges(fresh: Record<string, string>, before: Readonly<Record<string, string>>, after: Readonly<Record<string, string>>): boolean {
836  const prior = { ...fresh }
837  applyChanges(fresh, before, after)
838  for (const [pid, sid] of Object.entries(fresh)) {
839    if (prior[pid] === sid) continue
840    if (!Object.entries(fresh).some(([other, held]) => other !== pid && held === sid)) continue
841    if (prior[pid] === undefined) delete fresh[pid]
842    else fresh[pid] = prior[pid] as string
843  }
844  return !sameValue(fresh, prior)
845}
846
847/** The same for a list of thread ids: what the pass took off it comes off, what it added goes on, the rest is the store's. */
848function applyListChanges(fresh: string[], before: readonly string[], after: readonly string[]): boolean {
849  const off = before.filter(x => !after.includes(x))
850  const on = after.filter(x => !before.includes(x) && !fresh.includes(x))
851  const kept = fresh.filter(x => !off.includes(x))
852  if (kept.length === fresh.length && !on.length) return false
853  fresh.splice(0, fresh.length, ...kept, ...on)
854  return true
855}
856
857/** 1 to 3 seconds, spread by the thread's own id: a dozen threads that start together do not check again together. */
858function settleMs(id: string): number {
859  // FNV-1a, then mixed, so ids that differ in one character land far apart
860  let h = 0x811c9dc5
861  for (const c of id) h = Math.imul(h ^ c.charCodeAt(0), 0x01000193) >>> 0
862  h = Math.imul(h ^ (h >>> 16), 0x45d9f3b) >>> 0
863  h = (h ^ (h >>> 16)) >>> 0
864  return 1_000 + (h % 2_001)
865}
866
867/**
868 * Once, 1 to 3 seconds after a write the person's own act made (a marker's link, a session's own pair), it is
869 * read again and written again if it is gone: what two sessions meeting between a read and a write can still
870 * lose (patchStore), and what a set the engine could not lock in time never wrote. Anything going wrong here
871 * is swallowed: the next sync is the fallback.
872 */
873function settleLater($: EngineInterface, id: string, check: () => Promise<void>): void {
874  try {
875    $.clock.after(settleMs(id), () => void check().catch(() => {}))
876  } catch {
877    // no timer here: nothing is checked again
878  }
879}
880
881/**
882 * The app's list of threads does not say which Claude Code session runs in each, and the feed names a
883 * thread by that session id (it is what the worklog hook sends). So every session writes down its own
884 * pair, and the session that syncs reads them all: a thread opened since this version was installed can
885 * be matched to the client its session declared.
886 */
887async function recordSelf($: EngineInterface): Promise<void> {
888  const app = await selfId($)
889  const cli = await instanceId($)
890  if (!app || !cli) return
891  const now = await $.clock.now()
892  const mine = (await storeGet<Pairs>($, 'mm.threads', {}))[cli]
893  if (mine && mine.app === app && now - mine.at < DAY_MS) return
894  const write = () =>
895    patchStore<Pairs>($, 'mm.threads', {}, pairs => {
896      // (0.5.9) when the pair was first written for this thread: written with every first write, and kept through
897      // the daily rewrite. It is the thread's start for R1b (`threadStart`), so a thread the person goes back to is
898      // not made newer by it. ⛔ A pair written before 0.5.9 has none and gets none (M1): its `at` is its last
899      // rewrite, not when the thread started, and its thread's start stays unknown.
900      const was = pairs[cli]
901      if (!was || was.app !== app) pairs[cli] = { app, at: now, since: now }
902      else pairs[cli] = typeof was.since === 'number' && Number.isFinite(was.since) ? { app, at: now, since: was.since } : { app, at: now }
903      for (const [k, v] of Object.entries(pairs)) if (!v || now - v.at > PAIR_KEEP_MS) delete pairs[k]
904    })
905  // gone a moment later (another session's write met this one, or this one was never made): written again.
906  // A pair the app's own record has since given another thread is the app's word, and is left.
907  settleLater($, app, async () => {
908    if (!(await storeGet<Pairs>($, 'mm.threads', {}))[cli]) await write()
909  })
910  await write()
911}
912
913/** A thread linked by the person (a command, a button, a chip's first message) may link itself again later. */
914async function forgetUnlink($: EngineInterface, sid: string): Promise<void> {
915  await patchStore<Record<string, number>>($, 'mm.unlinked', {}, unlinked => {
916    if (!(sid in unlinked)) return false
917    delete unlinked[sid]
918  })
919}
920
921/** Where the desktop app keeps its record of each thread, by platform. The first folder that lists is used. */
922async function recordRoots($: EngineInterface): Promise<string[]> {
923  const roots: string[] = []
924  const appData = await $.env.get('APPDATA')
925  if (appData) roots.push(`${appData.replace(/[\\/]+$/, '')}\\Claude\\claude-code-sessions`)
926  const h = await home($)
927  if (h) roots.push(`${h}/Library/Application Support/Claude/claude-code-sessions`, `${h}/.config/Claude/claude-code-sessions`)
928  return roots
929}
930
931/**
932 * The second source for "which Claude Code session runs in which thread": the desktop app's own records,
933 * the `local_<id>.json` files under its `claude-code-sessions` folder. They exist for every thread, so a thread links itself without
934 * having to be opened again first (Rob, 2026-10-12: "Why are none of them linked?").
935 *
936 * ⛔ READ-ONLY, through the plugin's own file access. From each record ONLY two things are taken: the
937 * thread id and `cliSessionId`. Nothing else in a record is kept, logged or sent anywhere, and nothing
938 * from these files leaves this computer: the pairs are only compared with the feed's session ids here.
939 *
940 * ⛔ IT OPENS ONLY WHAT IT NEEDS. A record is opened only when it is the record of a thread the app listed
941 * in this sync, not archived, that has no pair in the store yet; at most RECORDS_PER_SYNC in one sync, the
942 * most recently modified first; never one not modified in 30 days. What a record said (its session id, or
943 * that it gave none) is remembered with its modified time, so it is not opened again until it changes,
944 * and each pair found goes into the store with the pairs the sessions wrote. The folder is listed, not
945 * read, to find those files. Any error at all (no such folder, a file that cannot be read or parsed, a
946 * different shape) is swallowed: what each session wrote down about itself is then the only source.
947 */
948async function readRecords($: EngineInterface, now: number, live: ReadonlyMap<string, AppThread>, pairs: Pairs): Promise<void> {
949  const paired = new Set(Object.values(pairs).map(p => p?.app))
950  const need = new Set<string>()
951  for (const s of live.values()) if (!s.isArchived && !paired.has(s.sessionId)) need.add(s.sessionId)
952  if (!need.size) return
953  const seen = await storeGet<Records>($, 'mm.records', {})
954  /** What this pass learnt from the records it opened: the only entries it writes. */
955  const learnt: Records = {}
956  const found: Pairs = {}
957  let opened = 0
958  for (const root of await recordRoots($)) {
959    const sep = root.includes('\\') ? '\\' : '/'
960    /** The newest record on disk for each thread that needs one. */
961    const newest = new Map<string, { path: string; mtimeMs: number }>()
962    const walk = async (dir: string, depth: number): Promise<void> => {
963      for (const entry of await $.fs.list(dir)) {
964        const path = `${dir}${sep}${entry.name}`
965        if (entry.kind === 'dir') {
966          // records sit two folders down today; one more level is allowed for
967          if (depth < 3) await walk(path, depth + 1).catch(() => {})
968          continue
969        }
970        const app = entry.kind === 'file' ? RECORD_FILE.exec(entry.name)?.[1] : undefined
971        if (!app || !need.has(app) || now - entry.mtimeMs > RECORD_MAX_AGE_MS) continue
972        const had = newest.get(app)
973        if (!had || entry.mtimeMs > had.mtimeMs) newest.set(app, { path, mtimeMs: entry.mtimeMs })
974      }
975    }
976    try {
977      await walk(root, 0)
978    } catch {
979      continue // this platform's folder is not here: try the next
980    }
981    const unread = [...newest].filter(([app, file]) => seen[app]?.m !== file.mtimeMs).sort((a, b) => b[1].mtimeMs - a[1].mtimeMs)
982    const said: Array<{ app: string; cli: string; mtimeMs: number }> = []
983    for (const [app, file] of unread.slice(0, RECORDS_PER_SYNC)) {
984      let cli = ''
985      try {
986        const record = JSON.parse(await $.fs.read(file.path)) as { sessionId?: unknown; cliSessionId?: unknown }
987        // the record must be this thread's own, and name a session id the hook could have sent
988        if ((record.sessionId === undefined || record.sessionId === app) && typeof record.cliSessionId === 'string' && SESSION_ID.test(record.cliSessionId)) {
989          cli = record.cliSessionId
990        }
991      } catch {
992        // unreadable, not JSON, too large: this record gives no pair
993      }
994      opened++
995      learnt[app] = seen[app] = cli ? { m: file.mtimeMs, cli } : { m: file.mtimeMs }
996      if (cli) said.push({ app, cli, mtimeMs: file.mtimeMs })
997    }
998    // A record just opened is the app's own word: it wins over a pair a session wrote down. Oldest first,
999    // so where two records name one session the more recently modified thread has it.
1000    // (0.5.9, M1) dated the day it was found, with no `since`: when its thread started is not known from it
1001    for (const r of said.sort((a, b) => a.mtimeMs - b.mtimeMs)) found[r.cli] = { app: r.app, at: now }
1002    // A record opened earlier whose pair has since dropped out of the store gives it back without being
1003    // opened again, unless another thread has that session by now.
1004    for (const [app, file] of newest) {
1005      const was = seen[app]
1006      if (was?.cli && was.m === file.mtimeMs && !pairs[was.cli] && !found[was.cli]) found[was.cli] = { app, at: now }
1007    }
1008    break
1009  }
1010  if (opened) {
1011    await patchStore<Records>($, 'mm.records', {}, records => {
1012      // each written last, so the cap keeps the records most recently opened
1013      for (const [app, said] of Object.entries(learnt)) {
1014        delete records[app]
1015        records[app] = said
1016      }
1017      capped(records)
1018    })
1019  }
1020  if (Object.keys(found).length) {
1021    Object.assign(pairs, found)
1022    // over a fresh copy: another session may have recorded itself since this pass began
1023    await patchStore<Pairs>($, 'mm.threads', {}, fresh => void Object.assign(fresh, found))
1024  }
1025}
1026
1027/**
1028 * Every known pair: what each session wrote down about itself, and what the app's records gave. The
1029 * records are looked at only when the feed names at least one session to match.
1030 */
1031async function allPairs($: EngineInterface, now: number, live: ReadonlyMap<string, AppThread>, feedThreads: readonly FeedThread[]): Promise<Pairs> {
1032  const pairs = await storeGet<Pairs>($, 'mm.threads', {})
1033  if (!feedThreads.length) return pairs
1034  try {
1035    await readRecords($, now, live, pairs)
1036  } catch {
1037    // no pairs from the app: the pairs each session records are the only source
1038  }
1039  return pairs
1040}
1041
1042/** What a client's thread has now (Update sent it, or a chip it opened with carried it): written last, so the cap keeps the newest. */
1043async function putSent($: EngineInterface, projectId: string, sent: Sent): Promise<void> {
1044  await patchStore<Record<string, Sent>>($, 'mm.sent', {}, all => {
1045    delete all[projectId]
1046    all[projectId] = sent
1047    capped(all)
1048  })
1049}
1050
1051/** The person made or used this link (Update, /mm link): its thread stays pinned for a day, and a pin they took off it comes back. */
1052async function stamp($: EngineInterface, projectId: string, sid: string): Promise<void> {
1053  const now = await $.clock.now()
1054  await patchStore<Acted>($, 'mm.acted', {}, acted => {
1055    for (const [pid, a] of Object.entries(acted)) if (!a || now - a.at >= ACTED_MS) delete acted[pid]
1056    acted[projectId] = { sid, at: now }
1057  })
1058  await pinAgain($, sid)
1059}
1060
1061/** The person acted on this thread's client (Update, Start, /mm link): the pin they took off it may come back. */
1062async function pinAgain($: EngineInterface, sid: string | undefined): Promise<void> {
1063  if (!sid) return
1064  await patchStore<Record<string, Unpinned>>($, 'mm.unpinned', {}, unpinned => {
1065    if (!(sid in unpinned)) return false
1066    delete unpinned[sid]
1067  })
1068}
1069
1070/**
1071 * The link is the person's from here on (Update, /mm link, /mm view, a chip they started), no longer one
1072 * the bridge made by itself: its thread may show the drafts pane, and the link is not undone by a sync.
1073 */
1074async function claim($: EngineInterface, projectId: string | undefined, sid: string): Promise<void> {
1075  await patchStore<Auto>($, 'mm.auto', {}, auto => {
1076    let changed = false
1077    for (const [pid, a] of Object.entries(auto)) {
1078      if (pid !== projectId && a?.sid !== sid) continue
1079      delete auto[pid]
1080      changed = true
1081    }
1082    return changed
1083  })
1084}
1085
1086/**
1087 * One pass over a feed: threads link themselves, the linked clients' threads are pinned and named up to
1088 * the cap (a thread the person acted on in the last day first, and never dropped by the cap), the rest
1089 * are let go, and every client gets its recommendation. `known` carries the pairs of an earlier pass of
1090 * the same feed: the app's records are looked at once, and a link's misses are counted once, per feed.
1091 * ⛔ Sends nothing and starts nothing.
1092 */
1093async function reconcile($: EngineInterface, feed: Feed, known?: Pairs): Promise<Pass> {
1094  const now = await $.clock.now()
1095  // Three lists, a client in exactly one. Only `items` pins and is counted. The other two are listed
1096  // under the list, closed: clients whose reply drafts a teammate holds, and clients whose only items are
1097  // more than 3 business days overdue (nothing drops off silently).
1098  const lists = buildLists(feed, now)
1099  const items = lists.queue
1100  const quiet = [...lists.teammate, ...lists.older]
1101  /** (R8) Clients whose package waits for the server's check of our newer send: in no list, their threads named for it and not pinned. */
1102  const checking = lists.checking
1103  /** The other lists as the sync record keeps them: only when somebody is in them. */
1104  const also = {
1105    ...(lists.teammate.length ? { teammate: lists.teammate } : {}),
1106    ...(lists.older.length ? { older: lists.older } : {}),
1107    ...(checking.length ? { checking } : {}),
1108  }
1109  const member = memberText(feed.member)
1110  const projects = feedProjects(feed)
1111  const listed = await sessions($)
1112  const live = new Map((listed?.threads ?? []).map(s => [s.sessionId, s] as const))
1113  const feedThreads = feedThreadsOf(feed)
1114  const before = await storeGet<Last | null>($, 'mm.last', null)
1115  /** The recommendation the last sync gave a client, in its words and without its thread: for a client whose thread cannot be seen now. */
1116  const carried = (it: QueueItem): Recommendation => {
1117    // (a sync by an older version stored the state under `action`)
1118    const was = before?.recs?.[it.projectId] as (Partial<Recommendation> & { action?: unknown }) | undefined
1119    const state = was?.state ?? was?.action
1120    const known = state === 'update' || state === 'start' || state === 'current'
1121    if (!was || !known || typeof was.reason !== 'string') return recommend([], undefined, now)
1122    // whether the client has no thread at all is carried too: a thread that cannot be seen now is still a thread
1123    const fresh = was.state !== undefined ? was.fresh === true : state === 'start' && !was.target
1124    return { state: state as RowState, reason: was.reason, ...(fresh ? { fresh: true as const } : {}) }
1125  }
1126
1127  // No thread list here (a terminal session, or an answer from the app that is not a list of threads): the
1128  // list of clients is shown, each with the recommendation the last sync in the app gave it, and nothing
1129  // about threads is touched. Links, pins, unlink records and stored titles stay exactly as they are.
1130  // ⛔ No recommendation carries a thread: with no list there is nothing for Update to act on.
1131  if (listed === null) {
1132    const recs: Record<string, Recommendation> = {}
1133    for (const it of [...items, ...quiet, ...checking]) recs[it.projectId] = carried(it)
1134    const last: Last = { at: now, member, items, ...also, pinned: before?.pinned ?? 0, recs, projects }
1135    await storeLast($, feed, last)
1136    const rowsOf = (list: readonly QueueItem[], needsYou = false): Row[] => list.map(it => rowFor(it, recs[it.projectId] as Recommendation, now, { needsYou }))
1137    await showQueue($, member, items, rowsOf(items, true), last.pinned, now, { teammate: rowsOf(lists.teammate), older: rowsOf(lists.older), checking: rowsOf(checking) })
1138    return { last, live, pairs: known ?? {}, listed: false, unseen: new Set() }
1139  }
1140
1141  const links = await storeGet<Record<string, string>>($, 'mm.links', {})
1142  const pins = await storeGet<Record<string, Pin>>($, 'mm.pins', {})
1143  /** (R18) The internal projects, and whether a pin record is on one: the bridge never unpins such a thread. */
1144  const internal = internalProjects(feed)
1145  const internalPin = (pin: Pin | undefined): boolean => Boolean(pin && (pin.internal === true || internal.has(pin.projectId)))
1146  /** (R18; fix round 1, L8) What an unpin becomes on an internal project's thread: only a pin the bridge put there comes off. */
1147  const unpinFor = (it: QueueItem, unpin: 'ours' | 'any'): 'ours' | 'any' | false => (it.internal || internal.has(it.projectId) ? 'ours' : unpin)
1148  /** A pin record for a client: (R18) marked when the client is internal. */
1149  const pinOf = (it: QueueItem, byUs: boolean): Pin => ({ projectId: it.projectId, client: it.client, byUs, ...(it.internal || internal.has(it.projectId) ? { internal: true as const } : {}) })
1150  const unlinked = await storeGet<Record<string, number>>($, 'mm.unlinked', {})
1151  const titles = await storeGet<Record<string, string>>($, 'mm.titles', {})
1152  const acted = await storeGet<Acted>($, 'mm.acted', {})
1153  const auto = await storeGet<Auto>($, 'mm.auto', {})
1154  const sent = await storeGet<Record<string, Sent>>($, 'mm.sent', {})
1155  const released = await storeGet<string[]>($, 'mm.released', [])
1156  const unpinned = await storeGet<Record<string, Unpinned>>($, 'mm.unpinned', {})
1157  /** (0.5.3) The linked threads whose client's clear this bridge has already acted on, with when: unpinned once per clear. */
1158  const cleared = await storeGet<Record<string, number>>($, 'mm.cleared', {})
1159  /** The client each thread's [mm-link] marker named: never a thread the bridge links to another by itself. */
1160  const marked = await storeGet<Record<string, string>>($, 'mm.marked', {})
1161  /** (0.5.10) When each thread's marker was recorded, where it is known: R1b's dated evidence. */
1162  const markedAt = await storeGet<Record<string, number>>($, 'mm.markedAt', {})
1163  /**
1164   * The shared maps as this pass read them. ⛔ The pass never writes one of them back whole: at its end each
1165   * gets only what the pass itself changed, over a fresh copy (`applyChanges`). Its app calls take seconds,
1166   * and a thread started meanwhile writes its own link into the same map (B1).
1167   */
1168  const was = { links: copyOf(links), pins: copyOf(pins), titles: copyOf(titles), auto: copyOf(auto), unpinned: copyOf(unpinned), cleared: copyOf(cleared), released: Array.isArray(released) ? [...released] : [] }
1169
1170  // ⛔ A thread missing from the app's list is not a thread that is gone: the list holds the 60 most
1171  // recent. Each linked or pinned thread the list left out is asked for by id. One the app shows open is a
1172  // thread like any other. One the app says does not exist, or shows archived, is `gone`. Anything else (an
1173  // error, no answer, more of them than one sync asks for) is `unseen`: its link, pin record and title are
1174  // kept exactly as they are, and nothing is done to it.
1175  // ⛔ And an empty list never drops anything at all, whatever a lookup says.
1176  const gone = new Set<string>()
1177  const unseen = new Set<string>()
1178  const missing = [...new Set([...Object.values(links), ...Object.keys(pins), ...released])].filter(sid => !live.has(sid))
1179  for (const [n, sid] of missing.entries()) {
1180    const found = n < LOOKUPS_PER_SYNC ? await lookup($, sid) : 'unknown'
1181    if (found === 'unknown') unseen.add(sid)
1182    else if (found !== 'gone') live.set(sid, found)
1183    else if (listed.complete) gone.add(sid)
1184    else unseen.add(sid)
1185  }
1186  const pairs = known ?? (await allPairs($, now, live, feedThreads))
1187
1188  // A link to a thread that is gone, or that the person unlinked (a sync in another thread may have written
1189  // an older copy of the links back after the unlink), is dropped.
1190  for (const [pid, sid] of Object.entries(links)) if (gone.has(sid) || sid in unlinked) delete links[pid]
1191  // ⛔ A thread is one client's. Where two clients are linked to one thread (an older version allowed it),
1192  // the client it is pinned for keeps it, else the first; the other link is dropped.
1193  const owners = new Map<string, string>()
1194  for (const [pid, sid] of Object.entries(links)) if (!owners.has(sid) || pins[sid]?.projectId === pid) owners.set(sid, pid)
1195  for (const [pid, sid] of Object.entries(links)) if (owners.get(sid) !== pid) delete links[pid]
1196
1197  // A link the bridge made by itself stops being one when the link is gone or the person acted on it.
1198  // (An entry of any other shape is dropped with them.)
1199  for (const [pid, a] of Object.entries(auto)) if (!a || typeof a.sid !== 'string' || links[pid] !== a.sid || acted[pid]?.sid === a.sid) delete auto[pid]
1200  // ⛔ And it is undone when it stops being true. At once, on this sync (B3, 2026-10-13), when it no longer
hooks/queue.ts 1048 lines
1// Pure queue logic for the Massmarket bridge: no `$`, so tests can call it directly.
2//
3// What pins (Rob, 2026-10-08): "for what shows up in the pins, we really just want to be talking about
4// what's going on right now, nothing more than 3 days old." A client pins when one of these is true:
5//   - an unanswered client message whose reply is due (24 business hours after it arrived) between
6//     3 business days ago and 24 business hours from now
7//   - a prepared reply package (drafts ready) less than 3 business days past its reply time
8//   - a reply package the viewer picked up, with a draft not sent, pins like a prepared one
9//   - a ticket with a date WE committed to, between 3 business days overdue and 24 business hours from now
10//     (clients never set dates; SLA and phase dates are not promises and do not pin)
11//   - for the founder alone: a reply package flagged for his OK, whoever owns the client (kind `ok`); one a
12//     teammate sends only once its reply is due (0.5.1, R2: until then it is theirs to decide)
13// Work that must start now at 1.5 h a day waits for real hour estimates (quote line items).
14//
15// Two more lists are built beside that one (buildLists), and neither pins or is counted: clients whose
16// reply drafts a teammate holds (not the viewer's to act on), and clients whose only items are more than
17// 3 business days overdue (so that nothing drops off silently).
18//
19// Every client clock here counts business time (Rob, 2026-10-12: "the weekends don't count as days towards
20// the 24-hour response time... We're only counting business days in our timelines for clients"): Saturday
21// and Sunday, America/New_York, contribute zero. See ./business-time.ts.
22
23import { businessDeadline, businessMsBetween } from './business-time'
24
25export type FeedMail = {
26  message_id_key: string
27  received_at: string
28  sender?: string
29  subject?: string
30  snippet?: string
31  project_id: string | null
32  project_name: string | null
33  reply_status: string
34  /** (0.5.2, R17) `null`: no reply time (internal work, unless a person set one). Absent: 24 business hours after it arrived, as before. */
35  reply_due_at?: string | null
36  project_owner_email?: string | null
37  /** (0.5.2, R18) The project is internal (Massmarket's own, a test or a template), when the feed says so. */
38  internal?: boolean
39  /** (0.5.2, R8) Our newest send to the client, when the feed gives it: what tells the bridge a reply went. */
40  last_outbound_at?: string | null
41}
42
43export type FeedDraft = {
44  package_id: string
45  project_id: string
46  project_name: string
47  status: string
48  covers_through?: string
49  /** When the package was last changed, when the feed gives it: part of what tells a thread it has the latest. */
50  updated_at?: string
51  drafts_ready?: number
52  /** Drafts not sent yet (ready, edited, sending, failed). Absent on a server that does not send it yet. */
53  drafts_unsent?: number
54  flagged_for_rob?: boolean
55  picked_up_by?: string | null
56  picked_up_at?: string | null
57  project_owner_email?: string | null
58  /**
59   * (0.5.2, R8) What the server's check of our latest send to the client found, when the feed gives it: the
60   * items it left open (team-safe short strings), the ones it answered, when it checked, and the time of the
61   * send it checked. Absent on a server that does not send them yet: the package is read as in 0.5.1.
62   */
63  still_open?: unknown
64  answered?: unknown
65  /** (0.5.2, R17) `null`: the package has no reply time (internal work). Absent: 24 business hours after what it covers, as before. */
66  due_at?: string | null
67  /** (0.5.2, R18) The project is internal, when the feed says so. */
68  internal?: boolean
69  checked_at?: string | null
70  checked_send_at?: string | null
71  last_outbound_at?: string | null
72}
73
74export type FeedTicket = {
75  ticket_id: string
76  project_id: string
77  project_name: string
78  title: string
79  /** (0.5.2, R17) `null`: no due time (internal work, unless a person set one). */
80  due_at: string | null
81  due_source?: string | null
82  assignee_email?: string | null
83  /** When the task was created, when the feed gives it. */
84  created_at?: string | null
85  /**
86   * (0.5.2, R1) When the work has to start to finish by its due time: OW's `start_by_at`, the due time less
87   * the ticket's estimate (`est_hours`) at 1.5 hours of work a business day (OW src/lib/start-by.ts). Null or
88   * absent with no estimate: the task acts by its due time.
89   */
90  start_by_at?: string | null
91  last_outbound_at?: string | null
92  /** (0.5.2, R18) The project is internal, when the feed says so. */
93  internal?: boolean
94}
95
96/**
97 * One of the caller's own Claude Code sessions that declared a client: the feed's `threads` field. The
98 * session id is what `$.session.id()` answers in that thread. Absent on a server that does not send it yet.
99 * `rows` is how many worklog rows that session wrote for that project in the feed's window: the thread's
100 * length when nothing better is known. Absent on a server that does not send it yet.
101 * `sole` is OW's word on whether the session declared no other project of any kind in the window: OW lists
102 * a session under one project at most, and only a project in the feed, so a session that also declared the
103 * internal-ops project, or a client outside the feed, shows here as that one row alone. `false`: it did
104 * (2026-10-13, the Pipeline-speed thread). Absent on a server that does not send it yet: the row is read
105 * as it always was.
106 */
107export type FeedThread = { project_id: string; session_id: string; last_activity_at: string; rows?: number; sole?: boolean }
108
109export type Feed = {
110  member: string
111  role: string
112  mail?: FeedMail[]
113  drafts?: FeedDraft[]
114  /** `start_now` (0.5.2, R1): committed tickets due after the due-soon window whose start-by time has come or is within 24 business hours. */
115  tickets?: { overdue?: FeedTicket[]; due_today?: FeedTicket[]; due_soon?: FeedTicket[]; start_now?: FeedTicket[] }
116  threads?: FeedThread[]
117}
118
119/**
120 * The client's open reply package when it is not simply "ready for you" (Rob, 2026-10-12: a thread that
121 * held the drafts was told none were prepared).
122 *   held_by_me     the viewer picked it up and a draft is not sent yet
123 *   held_by_other  a teammate picked it up; `by` is their name
124 *   stale          drafts exist but are marked out of date, and nobody holds it; for the founder, `by` is the
125 *                  teammate it is with when it is a teammate's (R1, 0.5.1), else ''
126 *   prepared       drafts are ready, but the client does not pin for the viewer on them (another owner's
127 *                  client, or a package older than the window)
128 * `unsent` is the drafts not sent on a held package, the drafts ready otherwise. `newer` is the client
129 * messages that arrived after a package the viewer holds was written.
130 */
131export type OpenPackage = {
132  id: string
133  state: 'held_by_me' | 'held_by_other' | 'stale' | 'prepared'
134  by: string
135  at: string | null
136  unsent: number
137  newer: number
138}
139
140/**
141 * A package that is a teammate's and not the viewer's to pick up or rebuild: one a teammate holds, and (for
142 * the founder) a stale one a teammate has (R1: `by` names them). The kickoff and the bridge note word the
143 * two alike: show what is waiting, run no prep, draft no second reply (Opus, 10-13, M2).
144 */
145export const withHolder = (o: OpenPackage): boolean => o.state === 'held_by_other' || (o.state === 'stale' && o.by !== '')
146
147/** The newest time of each kind of thing a client has for the viewer, in ms (0: none): a message received, the mail a package covers, a task created (or due, where the feed gives no creation time). */
148export type Newest = { mail: number; pkg: number; task: number }
149
150/** Who sends a package that waits for the founder's OK, when the feed names no owner for the client. */
151export const NOBODY = 'nobody on record'
152
153export type QueueItem = {
154  projectId: string
155  projectName: string
156  client: string
157  dueAt: number
158  /** `ok`: for the founder alone, a package flagged for his OK that somebody else sends. */
159  kind: 'ready' | 'reply' | 'task' | 'ok'
160  messages: number
161  tasks: number
162  packageId: string | null
163  coversThrough: string | null
164  /** The package's `updated_at` as the feed gave it, or null. Compared, never shown. */
165  packageUpdatedAt: string | null
166  flagged: boolean
167  mail: Array<{ at: string; sender: string; subject: string; snippet: string }>
168  taskTitles: string[]
169  open: OpenPackage | null
170  /** Set with kind `ok`: who sends the package once the founder has given his OK (the owner on record, cleaned, or NOBODY). Every read is `it.ok ?? null`. */
171  ok: { sends: string } | null
172  /** What "something new arrived" is measured against. Every read is `it.newest ?? …`: an item an older version stored has none. */
173  newest: Newest
174  /**
175   * For the founder alone: the teammate a client's package is with when it is theirs to see through, not his
176   * (0.5.1). R1: a stale package a teammate sends, with a client message waiting after its drafts. R2: a
177   * current package flagged for his OK that a teammate sends, until its reply is due. The client then lists
178   * under With a teammate. Absent otherwise, and on an item an older version stored.
179   */
180  theirs?: string
181  /**
182   * (0.5.2, R1) The soonest time something of the client's must be acted on: a reply's due time, a package's,
183   * a task's start-by time when the feed gives one, else its due time. Its thread is pinned while this is past
184   * or within the next 24 business hours. Every read is `it.actBy ?? it.dueAt`: an item an older version
185   * stored has none.
186   */
187  actBy?: number
188  /** (0.5.2, R8) Our newest send to the client in the feed, in ms (0: none given). */
189  lastOut?: number
190  /** (0.5.2, R8) The server's check of our latest send, on the client's own package: absent when the feed gives none. */
191  check?: PackageCheck | null
192  /**
193   * (0.5.2, R17) True when the client is listed for something with no due time (internal work, unless a person
194   * set one): its `dueAt` is then Infinity (stored as null), it is listed last, shows no clock, never pins and
195   * never gets a no-thread chip.
196   */
197  noDue?: true
198  /** (0.5.2, R18) An internal project (Massmarket's own, a test or a template): its thread is never unpinned by the bridge. */
199  internal?: true
200}
201
202/**
203 * (0.5.2, R18) The projects that are internal by id, while the feed carries no internal flag: Massmarket's
204 * internal-ops project and the Massmarket Core Snapshot. A feed row with `internal: true` makes any project so.
205 */
206export const INTERNAL_PROJECTS: ReadonlySet<string> = new Set(['e6deffc3-2713-4def-a11d-b75a1efba9c1', '3e439f40-a17f-48e2-bcfe-723af5b741fc'])
207
208/** (R17) Whether a client has a due time at all (an item stored by the bridge keeps Infinity as null). */
209export const hasDue = (it: Pick<QueueItem, 'dueAt'>): boolean => typeof it.dueAt === 'number' && Number.isFinite(it.dueAt)
210
211/** Soonest due first; a client with no due time last (R17). */
212export const byDue = (a: Pick<QueueItem, 'dueAt'>, b: Pick<QueueItem, 'dueAt'>): number => {
213  const x = hasDue(a) ? a.dueAt : Infinity
214  const y = hasDue(b) ? b.dueAt : Infinity
215  return x === y ? 0 : x < y ? -1 : 1
216}
217
218/**
219 * (0.5.2, R8) What the server's check found on the client's package: the time of the send it checked
220 * (`checked_send_at`, ms, 0 for none), what it left open, and what the package covers through (ms, 0 unknown).
221 */
222export type PackageCheck = { sendAt: number; stillOpen: string[]; covers: number }
223
224/**
225 * What a row under With a teammate adds to its sentence when the package there is flagged for the founder's
226 * OK (R2, Opus's words): the teammate decides whether it needs him (client-reply-prep §1 rule 13, §5.2).
227 */
228export const MAY_NEED_OK = 'may need your OK'
229
230/** (the founder, R2) What such a row's title and waiting text say in place of `needs your OK · <X> sends`, before its reply is due (Opus's words). */
231export const decidesText = (who: string): string => `${MAY_NEED_OK} · ${who} decides`
232
233const HOUR = 3_600_000
234const DAY = 24 * HOUR
235/**
236 * Both windows are business time: 3 business days back, 24 business hours ahead. (0.5.2, R1) The list holds
237 * everything the feed gives that is overdue by up to 3 business days or not due yet, however far ahead; the
238 * 24 business hours ahead are the pin's window (`pinsNow`): a client's thread pins while something of it must
239 * be acted on within them, or already should have been.
240 */
241export const WINDOW_BACK_MS = 3 * DAY
242export const WINDOW_AHEAD_MS = DAY
243/**
244 * (0.5.2, R8) How long a send waits for the server's check before the client is listed as it was: a check that
245 * never lands does not keep a client off the list. Real time (it is not a client's clock).
246 */
247export const CHECK_WAIT_MS = 2 * HOUR
248export const MAX_PINS = 8
249/**
250 * How far back the feed is asked for client mail, in business hours: OW's `mail_hours`, which takes 1 to
251 * 168. A reply is due 24 business hours after a message, and a client goes under Older once that is more
252 * than 3 business days past, so mail belongs under Older only when it is more than 96 business hours old.
253 * The 96 asked for until 2026-10-13 meant Older could never hold any mail (review of 79cbc596, F1). Rob, that
254 * day: "I kind of do think expanding that to 7 days would be better." The list keeps its 3-business-day
255 * cut; mail 3 to 7 business days overdue goes under Older. Mail OW sends from further back than this is read
256 * the same way, and goes under Older too.
257 */
258export const MAIL_LOOKBACK_HOURS = 168
259
260const ms = (iso: string | null | undefined): number => {
261  const t = iso ? Date.parse(iso) : NaN
262  return Number.isFinite(t) ? t : NaN
263}
264
265// ---------------------------------------------------------------- feed text is data
266// A project's name and id come from the feed, and a thread's title from the app. Both end up in places
267// Claude reads (a kickoff, a tool result, a thread's name), so neither is ever passed on as written.
268
269const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
270/** A lower-case uuid: the only shape of project id (or package id) the bridge acts on. */
271export const isUuid = (id: unknown): id is string => typeof id === 'string' && UUID.test(id)
272
273/**
274 * Text kept to letters, digits, spaces and . , & ' ( ) / - : every other character (a line break, a
275 * bracket, a quote, a symbol) becomes a space, runs of spaces collapse, and it is cut at `max`.
276 */
277export function cleanText(text: unknown, max: number): string {
278  const kept = (typeof text === 'string' ? text : '').normalize('NFC').replace(/[^\p{L}\p{N} .,&'()/-]/gu, ' ')
279  return kept.replace(/ +/g, ' ').trim().slice(0, max).trim()
280}
281/** A client or project name, as it may go into a kickoff, a result, a thread title or a tool argument. */
282export const cleanName = (name: unknown): string => cleanText(name, 80)
283/** A thread's title, as it may be shown in a sentence. */
284export const cleanTitle = (title: unknown): string => cleanText(title, 60)
285
286/** A teammate's name as it may be shown or passed on: the part of their address before the @, cleaned. */
287export function holderName(address: unknown): string {
288  const local = (typeof address === 'string' ? address : '').split('@')[0] ?? ''
289  const name = cleanText(local.replace(/[._-]+/g, ' '), 40).replace(/(^|\s)\p{L}/gu, c => c.toUpperCase())
290  return name || 'a teammate'
291}
292
293/** The client's short name: the project name before " — ", " - " or " (", cleaned. */
294export function shortName(projectName: string): string {
295  const cut = projectName.split(/\s+[—–-]\s+|\s+\(/)[0] ?? projectName
296  const name = cleanName(cut) || cleanName(projectName)
297  return name.length > 32 ? `${name.slice(0, 31)}…` : name
298}
299
300function isMine(owner: string | null | undefined, feed: Feed): boolean {
301  if (owner) return owner.toLowerCase() === feed.member.toLowerCase()
302  return feed.role === 'founder' // unowned clients are Rob's until someone owns them
303}
304
305/**
306 * businessMsBetween(fromMs, toMs) <= limit, without walking the calendar when real time already settles it.
307 * Business time never exceeds real time, so a stretch no longer than `limit` is within it. Each weekend
308 * adds at most 49 real hours (a 25-hour Sunday when the clocks go back) and a stretch holding `limit` of
309 * business time touches at most floor(limit / 5 days) + 1 weekends, so a longer stretch than that is not.
310 * Only what lies in between is measured, which keeps a far-off date in the feed from costing a long walk.
311 */
312function businessWithin(fromMs: number, toMs: number, limit: number): boolean {
313  const real = toMs - fromMs
314  if (real <= limit) return true
315  if (real > limit + (Math.floor(limit / (5 * DAY)) + 1) * (2 * DAY + HOUR)) return false
316  return businessMsBetween(fromMs, toMs) <= limit
317}
318
319/**
320 * Every client the feed has something of the viewer's for, in the three lists the pane draws. A client is
321 * in exactly one.
322 *   queue     needs the viewer now: what pins and what the header counts. Soonest due first.
323 *   teammate  a teammate holds its reply drafts and the viewer has no task of their own due there: not the
324 *             viewer's to act on (Rob, 2026-10-12: "They should only be seeing theirs... when you pass it
325 *             to somebody else, it should show up for only the person who's passed it"). Never pinned,
326 *             never counted. The pane lists them under `With a teammate (n)`.
327 *   older     its only items are more than 3 business days overdue (Rob, 2026-10-12: two clients with
328 *             unanswered mail from ten and six days ago were not shown at all). Never pinned, never
329 *             counted. The pane lists them last, under `Older (n)`, most overdue first.
330 */
331export type Lists = { queue: QueueItem[]; teammate: QueueItem[]; older: QueueItem[]; checking: QueueItem[] }
332
333/**
334 * A teammate holds this client's reply drafts and nothing else keeps it in the viewer's list: no committed
335 * task of the viewer's own due there, and (for the founder) no package waiting for his OK. For the founder
336 * too: a client whose package a teammate sends and that is theirs to see through (`theirs`, R1 and R2).
337 */
338export const withTeammate = (it: QueueItem): boolean =>
339  it.tasks === 0 && (Boolean(it.theirs) || ((it.open ?? null)?.state === 'held_by_other' && it.kind !== 'ok'))
340
341export function buildLists(feed: Feed, now: number): Lists {
342  const current = build(feed, now, false)
343  const inCurrent = new Set(current.map(i => i.projectId))
344  const past = build(feed, now, true).filter(i => !inCurrent.has(i.projectId))
345  // (0.5.2, R8) a client whose package waits for the server's check of our newer send is in none of the
346  // three lists: off the Needs-you list, not pinned, not counted. Its thread is named for it (`checkingTitle`).
347  const checks = (i: QueueItem): boolean => fastState(i, now)?.state === 'checking'
348  return {
349    queue: current.filter(i => !withTeammate(i) && !checks(i)),
350    teammate: [...current, ...past].filter(withTeammate),
351    older: past.filter(i => !withTeammate(i) && !checks(i)),
352    checking: [...current, ...past].filter(i => !withTeammate(i) && checks(i)),
353  }
354}
355
356// ---------------------------------------------------------------- 0.5.2: what pins, and a send being checked
357
358/**
359 * R1 (Rob, 2026-10-13: "Pinned is something the server recognized needs to be done within the next 24 hours,
360 * whether it's a reply or work that must be started within 24 hours to finish by an ETA"): whether a client on
361 * the Needs-you list pins now. Its act-by time (a reply's due time, a task's start-by time, else its due
362 * time) is past, or within the next 24 BUSINESS hours (weekends do not count: Friday 3 PM to Monday 3 PM is 24).
363 */
364export function pinsNow(it: Pick<QueueItem, 'dueAt'> & { actBy?: number }, now: number): boolean {
365  const at = Number.isFinite(it.actBy) ? (it.actBy as number) : it.dueAt
366  if (!Number.isFinite(at)) return false
367  return at <= now || businessWithin(now, at, WINDOW_AHEAD_MS)
368}
369
370/**
371 * (R18) The internal projects. Every project a feed row marks `internal: true`; and (fix round 1, L9)
372 * INTERNAL_PROJECTS only while no row of the feed carries the flag at all (a boolean either way): once OW sends
373 * it, the feed alone says.
374 */
375export function internalProjects(feed: Feed): Set<string> {
376  const flagged = new Set<string>()
377  let carries = false
378  const add = (row: { project_id?: string | null; internal?: boolean } | null | undefined): void => {
379    if (!row || typeof row.internal !== 'boolean') return
380    carries = true
381    if (row.internal && isUuid(row.project_id)) flagged.add(row.project_id)
382  }
383  for (const m of feed.mail ?? []) add(m)
384  for (const d of feed.drafts ?? []) add(d)
385  const t = feed.tickets ?? {}
386  for (const k of [t.overdue ?? [], t.due_today ?? [], t.due_soon ?? [], t.start_now ?? []]) for (const tk of k) add(tk)
387  return carries ? flagged : new Set(INTERNAL_PROJECTS)
388}
389
390/** Every client's newest `last_outbound_at`, from every row of it the feed gives one on, in ms. */
391function lastOutbound(feed: Feed): Map<string, number> {
392  const out = new Map<string, number>()
393  const add = (pid: unknown, at: unknown): void => {
394    const t = typeof at === 'string' ? ms(at) : NaN
395    if (!isUuid(pid) || !Number.isFinite(t)) return
396    out.set(pid, Math.max(out.get(pid) ?? 0, t))
397  }
398  for (const m of feed.mail ?? []) add(m?.project_id, m?.last_outbound_at)
399  for (const d of feed.drafts ?? []) add(d?.project_id, d?.last_outbound_at)
400  const t = feed.tickets ?? {}
401  for (const k of [t.overdue ?? [], t.due_today ?? [], t.due_soon ?? [], t.start_now ?? []]) for (const tk of k) add(tk?.project_id, tk?.last_outbound_at)
402  return out
403}
404
405/** At most this many items of what a check left open are kept, each cut to this length (team-safe short strings: OW keeps them under 120). */
406const STILL_OPEN_MAX = 8
407const STILL_OPEN_LEN = 120
408
409/** A package row's check, as the bridge keeps it: what is still open is server text, cleaned like any (it goes into a message Claude reads). */
410function checkOf(d: FeedDraft): PackageCheck {
411  const raw = Array.isArray(d.still_open) ? (d.still_open as unknown[]) : []
412  const stillOpen = raw.map(x => cleanText(x, STILL_OPEN_LEN)).filter(Boolean).slice(0, STILL_OPEN_MAX)
413  const sendAt = ms(d.checked_send_at)
414  const covers = ms(d.covers_through)
415  return { sendAt: Number.isFinite(sendAt) ? sendAt : 0, stillOpen, covers: Number.isFinite(covers) ? covers : 0 }
416}
417
418/** What R8 makes of a client: our newer send is being checked, or the check found something still open after it. */
419export type Fast = { state: 'checking'; at: number } | { state: 'still_open'; at: number; items: string[] }
420
421/**
422 * R8 (Rob, 2026-10-13, "yes fast close"), from the client's package and our newest send, both the feed's:
423 *   checking    our send is newer than the last check of the package (`last_outbound_at` > `checked_send_at`)
424 *               and newer than what the package covers, and no older than CHECK_WAIT_MS
425 *   still_open  the check of our send has landed (`checked_send_at` >= our newest send) and left items open
426 * null otherwise: no package, no check, nothing open, or a feed that gives none of these fields (0.5.1).
427 */
428export function fastOf(c: PackageCheck | null | undefined, lastOut: number, now: number): Fast | null {
429  if (!c) return null
430  const out = Number(lastOut) || 0
431  const checked = Number(c.sendAt) || 0
432  if (out > 0 && out > checked && out > (Number(c.covers) || 0)) return now - out <= CHECK_WAIT_MS ? { state: 'checking', at: out } : null
433  const items = Array.isArray(c.stillOpen) ? c.stillOpen : []
434  if (checked > 0 && items.length && !(out > checked)) return { state: 'still_open', at: checked, items }
435  return null
436}
437
438/**
439 * fastOf for a client as the lists hold it. (The founder's package that waits for his OK is somebody else's
440 * send: never.) Fix round 1, F3: never `checking` while something else of the client's still needs the person
441 * whatever our send answered: a client message that arrived after it, a committed task, or a package they hold
442 * with a draft not sent.
443 */
444export function fastState(it: QueueItem, now: number): Fast | null {
445  if (it.kind === 'ok') return null
446  const fast = fastOf(it.check ?? null, Number(it.lastOut) || 0, now)
447  if (fast?.state !== 'checking') return fast
448  const o = it.open ?? null
449  const newerMail = (Number(it.newest?.mail) || 0) > fast.at
450  if (newerMail || it.tasks > 0 || (o?.state === 'held_by_me' && o.unsent > 0)) return null
451  return fast
452}
453
454/** R8: the thread's name while the server checks our send: `✓ Acme Legal · sent 3 PM · checking`. */
455export function checkingTitle(client: string, at: number, now: number): string {
456  return `✓ ${client} · sent ${clockText(at, now)} · checking`
457}
458
459/** R8: the thread's name once the check found something still open: `🟡 Acme Legal · still open after your reply`. */
460export function stillOpenTitle(client: string): string {
461  return `🟡 ${client} · still open after your reply`
462}
463
464/** R4: the row of a client on the Needs-you list with no thread at all: `🔴 NO THREAD · Acme Legal · due 3 PM`. */
465export function noThreadTitle(it: QueueItem, now: number): string {
466  return `🔴 NO THREAD · ${it.client} · due ${clockText(it.dueAt, now)}`
467}
468
469/** The name a client's linked thread is given: still open after our reply (R8), else what is due (titleFor). */
470export function threadTitle(it: QueueItem, now: number): string {
471  return fastState(it, now)?.state === 'still_open' && !withTeammate(it) ? stillOpenTitle(it.client) : titleFor(it, now)
472}
473
474/** The clients that need the viewer now: what pins. Soonest due first. */
475export function buildQueue(feed: Feed, now: number): QueueItem[] {
476  return buildLists(feed, now).queue
477}
478
479/** A client as a name or an id may mean it: its id, its project's name and its short name, both cleaned. */
480export type KnownClient = Pick<QueueItem, 'projectId' | 'projectName' | 'client'>
481
482/**
483 * Every client the feed names, whether or not anything of it is waiting on the viewer: each project on a
484 * mail row, a reply package or a ticket (all three buckets), with the name that row gives it. What `/mm link`
485 * may mean beyond the three lists (Rob, 2026-10-13: a client with nothing waiting could not be linked).
486 * ⛔ The same rule as the lists: a lower-case uuid and a name with something left once cleaned, or nothing.
487 * The feed's `threads` carry no name: a project known only there is named by its id (register.tsx).
488 */
489export function feedClients(feed: Feed): KnownClient[] {
490  const out = new Map<string, KnownClient>()
491  const add = (projectId: unknown, projectName: unknown) => {
492    if (!isUuid(projectId) || out.has(projectId) || cleanName(projectName) === '') return
493    out.set(projectId, { projectId, projectName: cleanName(projectName), client: shortName(projectName as string) })
494  }
495  for (const m of feed.mail ?? []) add(m?.project_id, m?.project_name)
496  for (const d of feed.drafts ?? []) add(d?.project_id, d?.project_name)
497  const t = feed.tickets ?? {}
498  // (fix round 1, F5) `start_now` too: a client listed only by a task that must start now is a client the feed names
499  for (const k of [t.overdue ?? [], t.due_today ?? [], t.due_soon ?? [], t.start_now ?? []]) for (const tk of k) add(tk?.project_id, tk?.project_name)
500  return [...out.values()]
501}
502
503/**
504 * One pass over the feed. `older` false: what is overdue by up to 3 business days or due within the next
505 * 24 business hours. `older` true: only what is more than 3 business days overdue, by the same rules of
506 * whose it is.
507 */
508function build(feed: Feed, now: number, older: boolean): QueueItem[] {
509  const tooOld = (due: number): boolean => due < now && !businessWithin(due, now, WINDOW_BACK_MS)
510  // (0.5.2, R1) not due yet: listed however far ahead; whether it pins is `pinsNow`
511  const inWindow = (due: number): boolean => Number.isFinite(due) && (older ? tooOld(due) : due < now ? businessWithin(due, now, WINDOW_BACK_MS) : true)
512  /** (R8) Our newest send to each client, from every row of it the feed gives one on. */
513  const lastOut = lastOutbound(feed)
514  /** (R18) The internal projects: by the feed's own flag on any row, else by id. */
515  const internal = internalProjects(feed)
516  const founder = feed.role === 'founder'
517  const byProject = new Map<string, QueueItem>()
518  // ⛔ A row whose project id is not a lower-case uuid, or whose name has nothing left once cleaned, is
519  // dropped: the id goes into the [mm-link] marker and the name into the kickoff, both read by Claude.
520  const usable = (projectId: unknown, projectName: unknown): boolean => isUuid(projectId) && cleanName(projectName) !== ''
521  const item = (projectId: string, projectName: string): QueueItem => {
522    let it = byProject.get(projectId)
523    if (!it) {
524      it = {
525        projectId, projectName: cleanName(projectName), client: shortName(projectName), dueAt: Infinity, kind: 'task',
526        messages: 0, tasks: 0, packageId: null, coversThrough: null, packageUpdatedAt: null, flagged: false, mail: [], taskTitles: [], open: null,
527        ok: null, newest: { mail: 0, pkg: 0, task: 0 }, actBy: Infinity, lastOut: lastOut.get(projectId) ?? 0, check: null,
528        ...(internal.has(projectId) ? { internal: true as const } : {}),
529      }
530      byProject.set(projectId, it)
531    }
532    return it
533  }
534  /** The newest time of each kind of thing a client has: what "something new arrived" is measured against. */
535  const seen = (it: QueueItem, kind: keyof Newest, at: number): void => {
536    if (Number.isFinite(at) && at > it.newest[kind]) it.newest[kind] = at
537  }
538  /** (R1) The soonest something of the client's must be acted on. */
539  const act = (it: QueueItem, at: number): void => {
540    if (Number.isFinite(at)) it.actBy = Math.min(it.actBy ?? Infinity, at)
541  }
542
543  /** The mail rows counted for the viewer: a message is counted once. */
544  const counted = new Set<FeedMail>()
545  /**
546   * The feed's reply time is trusted when it gives one; otherwise 24 business hours after the message arrived.
547   * (R17, R18) None at all, Infinity, when the feed gives `null` or the project is internal: nobody set one.
548   */
549  const replyDue = (m: FeedMail): number =>
550    m.reply_due_at === null || internal.has(m.project_id ?? '') ? Infinity : Number.isFinite(ms(m.reply_due_at)) ? ms(m.reply_due_at as string) : businessDeadline(ms(m.received_at), DAY)
551  /** (R17) A row with no due time is listed in the current pass only (it is never overdue), and marks its client. */
552  const undated = (it: QueueItem): void => {
553    it.noDue = true
554  }
555  /** A client message waiting on our reply, on the client's item. */
556  const countMail = (it: QueueItem, m: FeedMail, due: number): void => {
557    counted.add(m)
558    it.messages += 1
559    it.dueAt = Math.min(it.dueAt, due)
560    act(it, due)
561    if (it.kind === 'task') it.kind = 'reply'
562    it.mail.push({ at: m.received_at, sender: m.sender ?? '?', subject: m.subject ?? '', snippet: m.snippet ?? '' })
563    seen(it, 'mail', ms(m.received_at))
564  }
565  for (const m of feed.mail ?? []) {
566    if (m.reply_status !== 'unanswered' || !m.project_id || !m.project_name) continue
567    if (!usable(m.project_id, m.project_name)) continue
568    if (!isMine(m.project_owner_email, feed)) continue
569    const due = replyDue(m)
570    if (due === Infinity) {
571      if (older) continue
572      const it = item(m.project_id, m.project_name)
573      countMail(it, m, due)
574      undated(it)
575      continue
576    }
577    if (!inWindow(due)) continue
578    countMail(item(m.project_id, m.project_name), m, due)
579  }
580
581  const me = typeof feed.member === 'string' ? feed.member.toLowerCase() : ''
582  /** (the founder) The clients whose row is a package that waits for his OK and that a teammate sends (R2). */
583  const okTheirs = new Set<string>()
584  /** (the founder) The teammate who sends a stale package of the client's, where a client message waits after its drafts (R1). */
585  const staleTheirs = new Map<string, string>()
586  /**
587   * (the founder) For each client with a stale package a teammate has: that teammate, and the client messages
588   * waiting after its drafts, in this pass's window. Counted only for the client's place under With a teammate,
589   * once nothing else of the founder's lists the client (below).
590   */
591  const staleMail = new Map<string, { name: string; mail: Array<{ m: FeedMail; due: number }> }>()
592  for (const d of feed.drafts ?? []) {
593    // the package id goes into the kickoff and the pickup request: a lower-case uuid or nothing
594    if (!usable(d.project_id, d.project_name) || !isUuid(d.package_id)) continue
595    const holder = (d.picked_up_by ?? '').toLowerCase()
596    const held = d.status === 'picked_up'
597    const ready = d.drafts_ready ?? 0
598    // R12 (0.5.2): the feed lists a picked-up package only while a draft of it is not sent (OW #455), whatever
599    // state that draft is in (`edited`, `sending`, `failed` are not `ready`): with no `drafts_unsent` given, a
600    // held package still has one. It is never read as "no drafts".
601    const unsent = typeof d.drafts_unsent === 'number' && Number.isFinite(d.drafts_unsent) ? d.drafts_unsent : held ? Math.max(1, ready) : ready
602    // What counts: a held package with a draft not sent, or a prepared or stale one with a draft ready.
603    if (held ? unsent < 1 || holder === '' : (d.status !== 'prepared' && d.status !== 'stale') || ready < 1) continue
604    // (the founder) A teammate has this package to see through (Opus, 10-13): the teammate who holds it, whoever
605    // owns the client; with nobody holding it, the owner on record when that is a teammate. ⛔ A package he
606    // holds is his, and so is one with no holder on a client with no owner on record (R3).
607    const sender = typeof d.project_owner_email === 'string' ? d.project_owner_email.toLowerCase() : ''
608    const mates = founder && (held ? holder !== me : sender !== '' && sender !== me)
609    // ⛔ R1 (0.5.1): a stale package a teammate has never puts a row on the founder's list, flagged or not
610    // (Rob's 16 rows, 10-13: Mugen, ReGen, Remver and Talon were answered by hand; the owner's stale reminder
611    // carries what is still open). So it is never a package that waits for his OK. (A stale package is held
612    // by nobody: it is the owner's on record.)
613    const staleTheirsHere = mates && d.status === 'stale'
614    const forOk = founder && d.flagged_for_rob === true && !staleTheirsHere
615    const heldByMe = held && holder === me
616    const it = item(d.project_id, d.project_name)
617    // (R17, R18) no reply time for a package the feed gives none, or on an internal project: Infinity
618    const noDue = d.due_at === null || internal.has(d.project_id)
619    const due = noDue ? Infinity : typeof d.due_at === 'string' && Number.isFinite(ms(d.due_at)) ? ms(d.due_at) : Number.isFinite(ms(d.covers_through)) ? businessDeadline(ms(d.covers_through), DAY) : now
620    // (this pass's side of the window: not yet 3 business days past its reply time, or only what is past that)
621    const inReach = noDue ? !older : older ? tooOld(due) : !tooOld(due)
622    // `by`: the teammate who holds it; for the founder, also the teammate a stale package is with (R1), so
623    // that its pane names them instead of inviting him to pick it up
624    const open = (state: OpenPackage['state']): OpenPackage => ({
625      id: d.package_id, state, by: state === 'held_by_other' ? holderName(holder) : state === 'stale' && staleTheirsHere ? holderName(sender) : '',
626      at: held ? d.picked_up_at ?? null : null, unsent: held ? unsent : ready, newer: 0,
627    })
628    // ⛔ The holder is asked first, the owner second: a package the viewer holds is theirs to finish whoever
629    // owns the client (Rob, 2026-10-12: a client owned by a teammate, picked up and sent by the founder,
630    // never appeared on the founder's list). A prepared one is theirs when the client is.
631    const readyForMe = d.status === 'prepared' && (holder === '' || holder === me) && isMine(d.project_owner_email, feed) && inReach
632    if (heldByMe || readyForMe) {
633      it.kind = 'ready'
634      it.packageId = d.package_id
635      it.coversThrough = d.covers_through ?? null
636      it.packageUpdatedAt = typeof d.updated_at === 'string' ? d.updated_at : null
637      it.flagged = Boolean(d.flagged_for_rob)
638      it.ok = null
639      // (R8) what the server's check of our latest send found on this package
640      it.check = checkOf(d)
641      if (inReach) {
642        it.dueAt = Math.min(it.dueAt, due)
643        act(it, due)
644        if (noDue) undated(it)
645        seen(it, 'pkg', ms(d.covers_through))
646      }
647      it.open = heldByMe ? open('held_by_me') : null
648      continue
649    }
650    // Any other open package never pins by itself: it only changes what the client's row, pane and note say.
651    if (it.packageId) continue
652    // ⛔ (the founder) A client listed for a package that waits for his OK keeps that package as its own: a
653    // later package of the client's that is not flagged (or that a teammate sends and is stale, R1) does not
654    // take its place on the row, in the pane or in the kickoff (B4, 2026-10-13).
655    if (it.kind === 'ok' && !forOk) continue
656    it.open = open(held ? 'held_by_other' : d.status === 'stale' ? 'stale' : 'prepared')
657    // (0.5.2, R8) A stale package of the viewer's own (nobody holds it, the client is theirs, no teammate sends
658    // it): while our latest send waits for the server's check, or the check left something open, the client is
659    // listed by it, so its thread is named for that and pins again on what is still open. Otherwise it changes
660    // nothing here, as before (M1: a stale package alone lists nothing).
661    if (d.status === 'stale' && !held && !staleTheirsHere && !forOk && isMine(d.project_owner_email, feed)) {
662      const c = checkOf(d)
663      it.check = c
664      if (fastOf(c, it.lastOut ?? 0, now) && inReach) {
665        it.dueAt = Math.min(it.dueAt, due)
666        act(it, due)
667        seen(it, 'pkg', ms(d.covers_through))
668      }
669    }
670    // R1: with a client message waiting after its drafts were built, the client lists under With a teammate,
671    // like any reply a teammate holds; with none, it is not listed. The messages are only noted here: they are
672    // counted after the tasks, and only where they are all that lists the client (Opus, 10-13, B1).
673    if (staleTheirsHere) {
674      const after = ms(d.covers_through)
675      const waiting = staleMail.get(d.project_id) ?? { name: holderName(sender), mail: [] }
676      for (const m of feed.mail ?? []) {
677        if (m.project_id !== d.project_id || m.reply_status !== 'unanswered' || waiting.mail.some(w => w.m === m)) continue
678        if (Number.isFinite(after) && !(ms(m.received_at) > after)) continue
679        const due = replyDue(m)
680        if (inWindow(due)) waiting.mail.push({ m, due })
681      }
682      if (waiting.mail.length) staleMail.set(d.project_id, waiting)
683      continue
684    }
685    // ⛔ With one exception, for the founder alone: a package flagged for his OK is listed whoever owns the
686    // client, as its own kind (Rob, 2026-10-12: six such packages were missing from his panel). It is due
687    // when a prepared one would be. Somebody else sends it: the owner on record.
688    // ⛔ Not the founder's own client's (or an unowned client's) STALE flagged package: with no client
689    // message waiting it lists nothing, and that stays so (Rob's 10-13 morning meeting, finding M1: 12 of
690    // 102 packages in 14 days went stale because we had already replied by hand; listing them would invite
691    // a second reply, and OW's fix will close such packages). With a message waiting, the message lists it.
692    if (forOk && (held || !isMine(d.project_owner_email, feed))) {
693      it.kind = 'ok'
694      it.ok = { sends: d.project_owner_email ? holderName(d.project_owner_email) : NOBODY }
695      // R2: one a teammate sends is theirs to decide until its reply is due (below)
696      if (mates) okTheirs.add(d.project_id)
697      else okTheirs.delete(d.project_id)
698      if (inReach) {
699        it.dueAt = Math.min(it.dueAt, due)
700        act(it, due)
701        seen(it, 'pkg', ms(d.covers_through))
702      }
703    }
704  }
705
706  const t = feed.tickets ?? {}
707  // (0.5.2, R1) `start_now` too: committed tasks due further out whose work must start within 24 business hours
708  const counting = new Set<string>()
709  for (const k of [t.overdue ?? [], t.due_today ?? [], t.due_soon ?? [], t.start_now ?? []]) {
710    for (const tk of k) {
711      // (R17) a ticket with no due time (internal work nobody dated) has no due source either: it lists, undated
712      const undatedRow = tk.due_at === null
713      if (tk.due_source !== 'commitment' && !(undatedRow && (tk.due_source === null || tk.due_source === undefined))) continue
714      if (!usable(tk.project_id, tk.project_name)) continue
715      if ((tk.assignee_email ?? '').toLowerCase() !== feed.member.toLowerCase()) continue
716      // a ticket is in one of the feed's lists only; counted once whatever a feed sends
717      if (typeof tk.ticket_id === 'string' && counting.has(tk.ticket_id)) continue
718      const due = undatedRow ? Infinity : ms(tk.due_at)
719      if (undatedRow ? older : !inWindow(due)) continue
720      if (typeof tk.ticket_id === 'string') counting.add(tk.ticket_id)
721      const it = item(tk.project_id, tk.project_name)
722      it.tasks += 1
723      it.dueAt = Math.min(it.dueAt, due)
724      // R1: a task acts by its start-by time when the feed gives one (OW's `start_by_at`, from the ticket's
725      // estimate), else by its due time
726      const startBy = ms(tk.start_by_at)
727      act(it, Number.isFinite(startBy) && startBy < due ? startBy : due)
728      if (undatedRow) undated(it)
729      it.taskTitles.push(tk.title)
730      seen(it, 'task', Number.isFinite(ms(tk.created_at)) ? ms(tk.created_at) : due)
731    }
732  }
733
734  // How many client messages arrived after the drafts of a package the viewer holds were written.
735  for (const it of byProject.values()) {
736    const through = ms(it.coversThrough)
737    if (it.open?.state !== 'held_by_me' || !Number.isFinite(through)) continue
738    const after = (feed.mail ?? []).filter(m => m.project_id === it.projectId && m.reply_status === 'unanswered' && ms(m.received_at) > through)
739    it.open.newer = after.length
740    // (they are new for this client whoever owns it: the package is the viewer's to finish)
741    for (const m of after) seen(it, 'mail', ms(m.received_at))
742  }
743
744  // (the founder) Whose a client is to see through, when it is a teammate's (0.5.1). ⛔ A package of his own,
745  // or one he holds, is his (R3).
746  //   R2: a current package flagged for his OK that a teammate sends is theirs to decide, the person holding it
747  //       approving it with a one-line reason or asking him (Rob, 10-07: client-reply-prep §1 rule 13, §5.2),
748  //       until its reply is due: the due time its row shows, in business time. From then it is in his list,
749  //       as the `ok` kind always was. With a teammate: whoever holds it, else the one who sends it.
750  //   R1: a stale package a teammate sends, with a client message waiting after its drafts. ⛔ Those messages
751  //       place the client under With a teammate and nothing more: where anything of his own lists the client
752  //       (a task of his, his own mail, a package of his or one that waits for his OK), his row is for that, and
753  //       they are not added to it (Opus, 10-13, B1: his task row was turned into a reply and pinned as one).
754  if (founder) {
755    for (const [pid, waiting] of staleMail) {
756      const it = byProject.get(pid)
757      if (!it || it.tasks > 0 || it.messages > 0 || it.kind !== 'task' || it.packageId) continue
758      for (const { m, due } of waiting.mail) if (!counted.has(m)) countMail(it, m, due)
759      if (it.messages > 0) staleTheirs.set(pid, waiting.name)
760    }
761    for (const it of byProject.values()) {
762      const o = it.open ?? null
763      const name =
764        it.kind === 'ok'
765          ? okTheirs.has(it.projectId) && it.dueAt > now ? (o?.state === 'held_by_other' ? o.by : (it.ok ?? null)?.sends) : undefined
766          : it.kind === 'ready' ? undefined : staleTheirs.get(it.projectId)
767      if (name) it.theirs = name
768    }
769  }
770
771  // (R17) a client with nothing dated is listed too, in the current pass, last
772  return [...byProject.values()]
773    .filter(i => Number.isFinite(i.dueAt) || (!older && i.noDue === true))
774    .sort(byDue)
775}
776
777// ---------------------------------------------------------------- names
778
779function etParts(t: number): { day: string; date: string; time: string } {
780  try {
781    const d = new Date(t)
782    const date = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit' }).format(d)
783    const day = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', weekday: 'short' }).format(d)
784    const time = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', hour: 'numeric', minute: '2-digit' })
785      .format(d)
786      .replace(':00', '')
787    return { day, date, time }
788  } catch {
789    // No Intl time zones: Eastern daylight time, which covers the weeks this ships in.
790    const d = new Date(t - 4 * HOUR)
791    const h = d.getUTCHours()
792    const mm = d.getUTCMinutes()
793    const time = `${h % 12 || 12}${mm ? `:${String(mm).padStart(2, '0')}` : ''} ${h < 12 ? 'AM' : 'PM'}`
794    return { day: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getUTCDay()] ?? '', date: d.toISOString().slice(0, 10), time }
795  }
796}
797
798export function whenText(due: number, now: number): string {
799  if (due < now) {
800    const late = businessMsBetween(due, now) // weekends do not count toward how late
801    // R16 (0.5.2): whole business days COMPLETED, so 1.5 days late is `1d` everywhere, never `2d` in one place
802    return late < DAY ? `overdue ${Math.max(1, Math.round(late / HOUR))}h` : `overdue ${Math.floor(late / DAY)}d`
803  }
804  const a = etParts(due)
805  const today = etParts(now)
806  const tomorrow = etParts(now + DAY)
807  if (a.date === today.date) return `due ${a.time}`
808  if (a.date === tomorrow.date) return `due tomorrow ${a.time}`
809  return `due ${a.day} ${a.time}`
810}
811
812export function isRed(it: QueueItem, now: number): boolean {
813  // (R17) nothing due: never red
814  if (!hasDue(it)) return false
815  return it.dueAt < now || etParts(it.dueAt).date === etParts(now).date
816}
817
818/** A moment as the pane writes times: `3 PM` when it is today in New York, `Fri 3 PM` when it is not. */
819export function clockText(t: number, now: number): string {
820  const a = etParts(t)
821  return a.date === etParts(now).date ? a.time : `${a.day} ${a.time}`
822}
823
824/**
825 * What is due, in a word or two: a package the viewer picked up is `your draft`, one that waits for the
826 * founder's OK `needs your OK`; one a teammate decides on before its reply is due `may need your OK ·
827 * <teammate> decides` (R2).
828 */
829const whatOf = (it: QueueItem): string =>
830  it.open?.state === 'held_by_me'
831    ? 'your draft'
832    : it.kind === 'ok' ? (it.theirs ? decidesText(it.theirs) : 'needs your OK') : it.kind === 'ready' ? 'reply ready' : it.kind === 'reply' ? 'reply' : 'task'
833
834/** What is due for a client and when, in words: `reply ready due tomorrow 8 AM`. */
835export function dueLine(it: QueueItem, now: number): string {
836  // (R17) nothing due: what it is, and no clock
837  return hasDue(it) ? `${whatOf(it)} ${whenText(it.dueAt, now)}` : whatOf(it)
838}
839
840/** A client and what is due, as a chip says it: `Acme Legal · reply ready due 7 PM · 3 new`, and who holds its drafts when a teammate does. */
841export function needsLine(it: QueueItem, now: number): string {
842  const parts = [it.client, dueLine(it, now)]
843  if (it.messages > 1) parts.push(`${it.messages} new`)
844  if (it.open?.state === 'held_by_other') parts.push(`with ${it.open.by}`)
845  if (it.flagged) parts.push('flagged')
846  return parts.join(' · ')
847}
848
849/**
850 * The business-time distance to a due instant, as a thread's name leads with it: `5h late`, `3d late`,
851 * `3h left`, `2d left`. Hours under 24 business hours, whole business days from there. Weekends do not count.
852 */
853export function clockLead(due: number, now: number): string {
854  const late = due < now
855  const span = late ? businessMsBetween(due, now) : businessMsBetween(now, due)
856  // under a business day: 1 to 23 hours (59 minutes short of a day is 23h, not 24h); from there, whole days
857  // R16 (0.5.2): whole business days COMPLETED (Math.floor): 1.49 and 1.51 days both read `1d`, 2.0 reads `2d`
858  const n = span < DAY ? `${Math.min(23, Math.max(1, Math.round(span / HOUR)))}h` : `${Math.floor(span / DAY)}d`
859  return `${n} ${late ? 'late' : 'left'}`
860}
861
862/**
863 * The pinned thread's name: `🔴 3d late · Acme Legal · reply · 17 new`. It leads with the hours (Rob,
864 * 2026-10-12: "hours before overdue or something like that, as the first part of the message").
865 */
866export function titleFor(it: QueueItem, now: number): string {
867  // A client whose drafts a teammate holds, and that is not the viewer's to act on: who has it stands where
868  // the clock would (`🟡 with J Smith · Acme Legal · reply`), and nothing about it reads as late.
869  const theirs = withTeammate(it)
870  // (`your draft` for a package the viewer holds, and `with <Name>` when a teammate holds it: 0.4.1; for the
871  // founder, the teammate it is with when it is theirs to see through: 0.5.1)
872  // (R17) nothing due: `🟡 <client> · <what>`, no clock and never red
873  const lead = theirs ? `🟡 with ${it.theirs ?? it.open?.by ?? ''}` : hasDue(it) ? `${isRed(it, now) ? '🔴' : '🟡'} ${clockLead(it.dueAt, now)}` : ''
874  const parts = lead ? [lead, it.client, whatOf(it)] : [`🟡 ${it.client}`, whatOf(it)]
875  if (it.messages > 1) parts.push(`${it.messages} new`)
876  if (it.open?.state === 'held_by_other' && !theirs) parts.push(`with ${it.open.by}`)
877  if (it.flagged) parts.push('flagged')
878  return parts.join(' · ')
879}
880
881/**
882 * For a client whose drafts a teammate holds: who has them, how many are not sent, and to ask before
883 * writing a second reply (0.4.1's own sentence, as the client's pane ends with it). '' for any other client.
884 */
885export function holdSentence(it: QueueItem): string {
886  return (it.open ?? null)?.state === 'held_by_other' ? draftsLine(it).replace(/^_|_$/g, '') : ''
887}
888
889export function clearedTitle(client: string, now: number): string {
890  return `✓ ${client} · clear ${etParts(now).time}`
891}
892
893/**
894 * The client a thread name of the bridge's names, whichever shape it has: today's
895 * (`🔴 3d late · Acme Legal · reply`, or `🟡 with J Smith · Acme Legal · reply` when a teammate holds its
896 * drafts), the one before it (`🔴 Acme Legal · reply overdue 6h`), the cleared one
897 * (`✓ Acme Legal · clear 11 AM`), with or without a two-digit rank in front. For any other title, what
898 * stands before its first ` · `.
899 */
900export function clientInTitle(title: string): string {
901  const marked = /^[🔴🟡✓]\s/u.test(title.replace(/^\d{2}\s+/, ''))
902  const parts = title.replace(/^\d{2}\s+/, '').replace(/^[🔴🟡✓]\s*/u, '').replace(/^client:\s*/i, '').split(' · ')
903  const first = parts[0]?.trim() ?? ''
904  // what leads the name: the clock, or (in a name of the bridge's only) who holds the drafts, or (0.5.2) that
905  // the client has no thread
906  const lead = /^\d+[hd] (?:late|left)$/.test(first) || (marked && parts.length > 2 && /^with \S/.test(first)) || (marked && first === 'NO THREAD')
907  return lead ? (parts[1]?.trim() ?? '') : first
908}
909
910/** (0.5.5) A version as plugin.json states one: three whole numbers. Anything else (`0.6`, `latest`, `1.0.0-beta`) is no version here. */
911const SEMVER = /^(\d{1,4})\.(\d{1,4})\.(\d{1,6})$/
912export const isVersion = (v: unknown): v is string => typeof v === 'string' && SEMVER.test(v)
913
914/** (0.5.5) Whether `a` is a later version than `b`, by number, part by part (never by string: `0.10.0` is later than `0.9.0`). A malformed `a` never is. */
915export function isNewerVersion(a: unknown, b: unknown): boolean {
916  const x = typeof a === 'string' ? SEMVER.exec(a) : null
917  if (!x) return false
918  const y = typeof b === 'string' ? SEMVER.exec(b) : null
919  if (!y) return true
920  for (let i = 1; i <= 3; i++) {
921    const d = Number(x[i]) - Number(y[i])
922    if (d !== 0) return d > 0
923  }
924  return false
925}
926
927/** (0.5.4) The most characters of a thread's own name the band gives. */
928const SHORT_TITLE_MAX = 40
929
930/**
931 * (0.5.4, C2/C3) A thread's name as the band gives it: for a name of the bridge's, the client it is for (its clock
932 * changes by the hour); for any other, the name itself, cleaned and cut to SHORT_TITLE_MAX characters.
933 */
934export function threadTitleShort(title: unknown): string {
935  const raw = typeof title === 'string' ? title : ''
936  // (our marks, read before the name is cleaned: cleaning takes them off)
937  if (/^(?:\d{2}\s+)?[🔴🟡✓]\s/u.test(raw)) {
938    const client = cleanName(clientInTitle(raw))
939    if (client) return client
940  }
941  const t = cleanTitle(raw)
942  return t.length > SHORT_TITLE_MAX ? `${t.slice(0, SHORT_TITLE_MAX - 1)}…` : t
943}
944
945/** A session title names this client when, stripped of our marks, it starts with the client's name. */
946export function titleNames(title: string, client: string): boolean {
947  const bare = clientInTitle(title).toLowerCase()
948  const c = client.toLowerCase().replace(/…$/, '')
949  return bare.length > 0 && (bare === c || bare.startsWith(c) || (bare.length >= 6 && c.startsWith(bare)))
950}
951
952const pickedAt = (at: string | null): string => {
953  const t = ms(at)
954  return Number.isFinite(t) ? ` (${etParts(t).day} ${etParts(t).time})` : ''
955}
956const draftCount = (n: number): string => `${n} draft${n === 1 ? '' : 's'}`
957
958/** The last line of a client's pane when the pickup view is not shown: what is true of its reply drafts. */
959export function draftsLine(it: QueueItem): string {
960  const ask = (verb: string) => `Ask Claude: "${verb} the reply drafts for ${it.client}"`
961  const o = it.open ?? null
962  if (o?.state === 'held_by_me') return `_You picked up the reply drafts for this client: ${draftCount(o.unsent)} not sent yet. ${ask('Show me')}._`
963  if (it.packageId) return `_Reply drafts are prepared for this client, but the pickup view could not be loaded here. ${ask('Show me')}._`
964  if (o?.state === 'held_by_other') return `_${o.by} picked up the reply drafts for this client${pickedAt(o.at)} and has ${draftCount(o.unsent)} not sent yet. Ask ${o.by} before writing a second reply._`
965  // (the founder) a stale package a teammate has: not his to pick up (R1, Opus's words)
966  if (o?.state === 'stale' && o.by) return `_${o.by} has these drafts. Their reminder lists what is still open._`
967  if (o?.state === 'stale') return `_Reply drafts exist for this client but are marked out of date. ${ask('Pick up')} and it will rebuild them first._`
968  if (o?.state === 'prepared') return `_Reply drafts are prepared for this client. ${ask('Pick up')}._`
969  return `_No reply drafts are prepared yet. ${ask('Pick up')}._`
970}
971
972/** One line above the pickup view when the viewer holds the package: that they hold it, what is unsent, what arrived since. */
973export function holdBanner(it: QueueItem): string {
974  const o = it.open ?? null
975  if (o?.state !== 'held_by_me') return ''
976  const newer = o.newer > 0 ? ` ${o.newer} client message${o.newer === 1 ? '' : 's'} arrived after ${o.unsent === 1 ? 'it was' : 'they were'} written.` : ''
977  return `> **You picked this up**${pickedAt(o.at)}. ${draftCount(o.unsent)} not sent yet.${newer}`
978}
979
980/** What the bridge's note tells Claude about a client's reply drafts and what is waiting. `member` is the viewer's address. */
981export function noteText(it: QueueItem, member: string): string {
982  const waiting = `${it.messages} client message${it.messages === 1 ? '' : 's'} waiting on our reply and ${it.tasks} committed task${it.tasks === 1 ? '' : 's'} due`
983  const o = it.open ?? null
984  if (o?.state === 'held_by_me' && it.packageId) {
985    const newer = o.newer > 0
986      ? ` ${o.newer} client message${o.newer === 1 ? '' : 's'} arrived after ${o.unsent === 1 ? 'that draft was' : 'those drafts were'} written: read the package again and revise the open draft, never write a second email.`
987      : ''
988    return `Reply package ${it.packageId.slice(0, 8)} is already picked up by ${holderName(member)}, the person in this session, with ${draftCount(o.unsent)} not sent yet${it.flagged ? ', flagged for approval' : ''}; the five-part pickup view is in the side pane.${newer} The drafts are being worked in the thread that picked them up: to act on them, follow client-reply-prep §9 with --package ${it.packageId}.`
989  }
990  if (it.packageId) return `Reply package ${it.packageId.slice(0, 8)} is prepared${it.flagged ? ' and flagged for approval' : ''}; the five-part pickup view is in the side pane. When the person asks to act on it, follow client-reply-prep §9 with --package ${it.packageId}.`
991  if (o?.state === 'held_by_other') return `${waiting}; ${o.by} picked up the reply drafts for this client (package ${o.id.slice(0, 8)}) and has ${draftCount(o.unsent)} not sent yet. Do not write a second reply: ask ${o.by}.`
992  // (the founder) a stale package a teammate has is treated as one a teammate holds: never "pick it up" (M2),
993  // its first sentence in Opus's words for a package nobody picked up
994  if (o && withHolder(o)) return `${waiting}; ${o.by} has the reply drafts (package ${o.id.slice(0, 8)}), and they are marked out of date. Do not write a second reply: ask ${o.by}.`
995  if (o?.state === 'stale') return `${waiting}; reply drafts exist (package ${o.id.slice(0, 8)}) but are marked out of date. When the person asks for the reply, pick the package up and rebuild the drafts first (client-reply-prep §9 with --package ${o.id}).`
996  if (o?.state === 'prepared') return `${waiting}; reply drafts are prepared for this client (package ${o.id.slice(0, 8)}). When the person asks to act on them, follow client-reply-prep §9 with --package ${o.id}.`
997  return `${waiting}; no reply drafts are prepared yet.`
998}
999
1000/** What a client's thread shows when the pickup view is not shown: the messages waiting, the tasks due, and what is true of its reply drafts. */
1001export function clientMarkdown(it: QueueItem, now: number): string {
1002  const out = [`## ${it.projectName}`, `${titleFor(it, now)}`]
1003  if (it.mail.length) {
1004    out.push('', '### Waiting on our reply')
1005    for (const m of it.mail.sort((a, b) => a.at.localeCompare(b.at))) {
1006      out.push(`- **${m.sender}** · ${etParts(Date.parse(m.at)).day} ${etParts(Date.parse(m.at)).time} · ${m.subject}`)
1007      if (m.snippet) out.push(`  > ${m.snippet.replace(/\s+/g, ' ').slice(0, 220)}`)
1008    }
1009  }
1010  if (it.taskTitles.length) {
1011    out.push('', '### Due (dates we committed to)')
1012    for (const t of it.taskTitles) out.push(`- ${t}`)
1013  }
1014  out.push('', draftsLine(it))
1015  return out.join('\n')
1016}
1017
1018// ---------------------------------------------------------------- a route call that can close something
1019
1020/**
1021 * The team gateway's routes that can close something for a client (R6, Rob 2026-10-13: names and pins change
1022 * only from the server's data, and the feed is read again at once after any of these): the reply-draft routes
1023 * that end a draft (`sent`, `discard`, and `post-team`, which posts a team draft and records it sent in the same
1024 * call, client-reply-prep §9.5), and ANY write to a reply package (`/reply-package`, `/reply-package/no-reply`,
1025 * `/reply-package/<anything>`). Each can change what the feed lists for the client. Judged by the route and the
1026 * method, never by a fixed list of commands.
1027 */
1028const CLOSING_ROUTE = String.raw`\/(?:reply-draft\/(?:sent|discard|post-team)|reply-package(?:\/[\w-]+)*)(?![\w\/-])`
1029/** A write: what a read (GET) never is. */
1030const WRITE = String.raw`(?:POST|PATCH|PUT|DELETE)`
1031/** As the gateway helper is called: `mm-api.mjs ext POST /reply-draft/sent ...`, the path quoted or not. */
1032const VIA_HELPER = new RegExp(String.raw`\bmm-api\.mjs["']?\s+ext\s+${WRITE}\s+["']?${CLOSING_ROUTE}`)
1033/** And as the route itself is called: a write to `.../api/ext/reply-draft/sent`. */
1034const VIA_URL = new RegExp(String.raw`\/api\/ext${CLOSING_ROUTE}`)
1035const WRITE_WORD = new RegExp(String.raw`\b${WRITE}\b`)
1036
1037/**
1038 * Whether a shell command is a call to one of those routes. ⛔ This is all the bridge ever looks at of a
1039 * command: it answers yes or no, and nothing of the command is kept, logged or passed on.
1040 * The route is matched, never a loose word: `revise`, `claim-send`, `rob-ok`, `release` and a GET are not it.
1041 */
1042export function isClosingRoute(command: unknown): boolean {
1043  return typeof command === 'string' && command.length <= 200_000 && (VIA_HELPER.test(command) || (VIA_URL.test(command) && WRITE_WORD.test(command)))
1044}
1045
1046/** The name 0.5.1 gave it (a reply draft was sent or discarded); the same test now. */
1047export const isDraftDone = isClosingRoute
1048
hooks/threads.ts 1010 lines
1// Pure thread logic for the Massmarket bridge: no `$`, so tests can call it directly.
2//
3// Rob, 2026-10-12: "There should be a process that should already be looking to see what threads exist and
4// deciding whether we should start a new one or not, and then making it really easy to pick up... it
5// should be based on a real recommendation." And: "Why are none of them linked? Shouldn't they all be linked?"
6//
7// A client's threads are found three ways:
8//   (i)   a thread already linked to the client (by hand, from a chip, or by an earlier sync)
9//   (ii)  a thread whose Claude Code sessions declared the client and no other project: the feed's
10//         `threads` rows, matched to the app's threads through the pairs each session records about itself
11//   (iii) a thread whose title merely looks like the client
12// (i) and (ii) link themselves. (iii) never does: it is only offered on the row as a suggestion. A thread
13// whose sessions also declared another project is none of these, for either client (`onlyClients`).
14//
15// ⛔ A thread is one client's. A thread linked to one client is never another client's thread, whatever
16// session or title matched it: it is not linked to a second, and it is never the thread Update acts on
17// for a second (`ownerOf`).
18
19import { businessMsBetween } from './business-time'
20import type { MmRow } from '../types'
21
22import { MAY_NEED_OK, NOBODY, checkingTitle, cleanTitle, clockText, decidesText, fastState, hasDue, holdSentence, noThreadTitle, pinsNow, stillOpenTitle, titleFor, titleNames, withHolder, withTeammate } from './queue'
23import type { FeedThread, QueueItem } from './queue'
24
25const HOUR = 3_600_000
26const DAY = 24 * HOUR
27const TZ = 'America/New_York'
28
29/** A thread counts as current for a client when its last activity is within this much business time. */
30export const RECENT_THREAD_MS = 7 * DAY
31/** Last activity further back than this is old, decided without counting business time (no calendar walk for a date years back). */
32export const OLD_THREAD_MS = 60 * DAY
33
34// (0.5.11, Rob 10-15: "Can you just automatically compress context at 60% instead of 100% to save tokens?") A thread's
35// length decides nothing any more: Claude Code compacts a long thread (CLAUDE_AUTOCOMPACT_PCT_OVERRIDE), so it is
36// never one to replace, and its row offers Claim whatever its length.
37
38/** A thread as the desktop app lists it. `link` is the app's own link to it, kept only when it is that thread's. */
39export type AppThread = { sessionId: string; title?: string; pinned?: boolean; isArchived?: boolean; lastActivityAt?: string; link?: string }
40
41/**
42 * Claude Code session id → the app thread it runs in, as each session records about itself. `at` is when the
43 * pair was last written (a session writes it again once a day, and a pair not written for 30 days is
44 * forgotten); `since` (0.5.9) is when it was first written for that thread, written with every first write and
45 * kept through those rewrites. A pair with no `since` (written before 0.5.9, or found late in the app's records
46 * and dated then) says nothing about when its thread started.
47 */
48export type Pairs = Record<string, { app: string; at: number; since?: number }>
49
50export type ClientThread = { sid: string; title: string; lastAt: number; source: 'linked' | 'matched' }
51
52export type Target = { sid: string; title: string; suggested: boolean }
53
54/** `start`: a new thread. `update`: the thread exists and lacks what is waiting. `current`: it has it. */
55export type RowState = 'start' | 'update' | 'current'
56
57export type Recommendation = {
58  state: RowState
59  /** Why, in a few words: shown on the row beside the action. For `current`, the whole phrase: `Thread updated 3 PM`. */
60  reason: string
61  /** The client's thread: what Update or Open acts on, and what a new thread takes over from. Absent when there is none. */
62  target?: Target
63  /** True when the client has no thread of any kind: the rows to notice first. (Kept when the thread is merely not visible now.) */
64  fresh?: true
65}
66
67/**
68 * What Update last put into a client's thread: which thread, the signature of what it carried, and when.
69 * (0.5.11, review of PR #15, F3) `prep`: it went by itself and asked the thread to build the client's package
70 * (kickoff case 6, `asksForPrep`): the package that then appears is the thread's own work, not something new for it.
71 */
72export type Sent = { sid: string; sig: string; at: number; prep?: true }
73
74/** The client a thread is linked to: the first project whose link is that thread. */
75export function ownerOf(sid: string, links: Readonly<Record<string, string>>): string | undefined {
76  for (const [projectId, linked] of Object.entries(links)) if (linked === sid) return projectId
77  return undefined
78}
79
80const ms = (iso: string | null | undefined): number => {
81  const t = iso ? Date.parse(iso) : NaN
82  return Number.isFinite(t) ? t : NaN
83}
84
85function fmt(t: number, opts: Intl.DateTimeFormatOptions): string {
86  try {
87    // some runtimes put a narrow no-break space before AM and PM
88    return new Intl.DateTimeFormat('en-US', { timeZone: TZ, ...opts }).format(new Date(t)).replace(/\u202f/g, ' ')
89  } catch {
90    // No Intl time zones: the UTC date, which is the same day for most of the working day.
91    return new Date(t).toISOString().slice(0, 10)
92  }
93}
94
95/** `Oct 12` */
96export const monthDay = (t: number): string => fmt(t, { month: 'short', day: 'numeric' })
97/** `Mon, Oct 12` */
98export const weekdayMonthDay = (t: number): string => fmt(t, { weekday: 'short', month: 'short', day: 'numeric' })
99/** `Mon, Oct 12 3 PM ET` */
100export function dueText(t: number): string {
101  const time = fmt(t, { hour: 'numeric', minute: '2-digit' }).replace(':00', '')
102  return `${weekdayMonthDay(t)} ${time} ET`
103}
104
105const slug = (s: string): string => s.toLowerCase().replace(/…$/, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
106
107/** A title looks like a client when it reads `client:<slug>`, or, stripped of our marks, starts with the client's name. */
108export function titleLooksLike(title: string, client: string): boolean {
109  const tagged = /^client:\s*(\S+)/i.exec(title.trim())
110  if (tagged && slug(tagged[1] ?? '') === slug(client) && slug(client) !== '') return true
111  return titleNames(title, client)
112}
113
114/**
115 * The feed's threads for one client that are open in the app now, most recently active first.
116 * A session the app does not know (another computer, a headless run, opened before this plugin) is skipped.
117 * ⛔ So is a thread linked to a different client: one app thread can have run sessions for two clients (a
118 * /clear, a resume), and it stays the client's it is linked to.
119 * "Last activity" is the app's own for the thread; the feed's time stands in only when the app gives none.
120 */
121function matchedThreads(
122  projectId: string,
123  links: Readonly<Record<string, string>>,
124  feedThreads: readonly FeedThread[],
125  pairs: Pairs,
126  live: ReadonlyMap<string, AppThread>,
127): ClientThread[] {
128  const out: ClientThread[] = []
129  for (const t of feedThreads) {
130    if (t.project_id !== projectId) continue
131    const app = pairs[t.session_id]?.app
132    const s = app ? live.get(app) : undefined
133    if (!s || s.isArchived || out.some(o => o.sid === s.sessionId)) continue
134    const owner = ownerOf(s.sessionId, links)
135    if (owner !== undefined && owner !== projectId) continue
136    const at = ms(s.lastActivityAt)
137    out.push({ sid: s.sessionId, title: s.title ?? '', lastAt: Number.isFinite(at) ? at : ms(t.last_activity_at), source: 'matched' })
138  }
139  return out.sort((a, b) => (Number.isFinite(b.lastAt) ? b.lastAt : -Infinity) - (Number.isFinite(a.lastAt) ? a.lastAt : -Infinity))
140}
141
142/**
143 * The app threads reached by sessions of more than one client: one thread that ran a session for one
144 * client and, after a /clear or a resume, a session for another. ⛔ Such a thread is nobody's by itself: it
145 * is never linked automatically, to either, and neither client's row offers it (`clientThreads`). The person
146 * can still type /mm link in it.
147 */
148export function sharedThreads(feedThreads: readonly FeedThread[], pairs: Pairs): Set<string> {
149  const clients = new Map<string, Set<string>>()
150  for (const t of feedThreads) {
151    const app = pairs[t.session_id]?.app
152    if (!app) continue
153    const of = clients.get(app) ?? new Set<string>()
154    of.add(t.project_id)
155    clients.set(app, of)
156  }
157  return new Set([...clients].filter(([, of]) => of.size > 1).map(([app]) => app))
158}
159
160/**
161 * The one client a thread's sessions declared in the feed's window: the project EVERY feed row of every
162 * session that runs in the thread names. Undefined when there is no such row, when the rows name two or
163 * more projects, whichever they are (the internal-ops project counts like any other), or when OW says one
164 * of those sessions declared another project too, one the feed does not list (`sole: false`). B3,
165 * 2026-10-13: a pipeline thread that loaded one client's skill for reference was linked to that client,
166 * pinned and named as its thread, so that client's Update would have sent into it.
167 */
168export function soleClient(sid: string, feedThreads: readonly FeedThread[], pairs: Pairs): string | undefined {
169  let only: string | undefined
170  for (const t of feedThreads) {
171    if (pairs[t.session_id]?.app !== sid) continue
172    if (t.sole === false) return undefined
173    if (only !== undefined && only !== t.project_id) return undefined
174    only = t.project_id
175  }
176  return only
177}
178
179/**
180 * Whether a thread is, by the feed or by its own marker, another client's as well as or instead of this
181 * one's: a row of its sessions names another project, OW says such a session declared another project too
182 * (`sole: false`), or the thread opened with an [mm-link] marker for another client (`marked`: the project
183 * each thread's marker named). A row with no `sole` (an older OW) is read as it always was.
184 */
185export function declaredOther(projectId: string, sid: string, feedThreads: readonly FeedThread[], pairs: Pairs, marked: Readonly<Record<string, string>> = {}): boolean {
186  if (marked[sid] !== undefined && marked[sid] !== projectId) return true
187  return feedThreads.some(t => pairs[t.session_id]?.app === sid && (t.project_id !== projectId || t.sole === false))
188}
189
190/**
191 * Whether a thread is this client's alone: what the bridge may link by itself, keep linked by itself, and
192 * offer on the client's row. Every feed row of its sessions names that client (`soleClient`), and nothing
193 * makes it another's (`declaredOther`). A thread the feed lists for nobody is not; whether a link the
194 * bridge made to such a thread is undone is the sync's count of misses.
195 */
196export function onlyClients(projectId: string, sid: string, feedThreads: readonly FeedThread[], pairs: Pairs, marked: Readonly<Record<string, string>> = {}): boolean {
197  return !declaredOther(projectId, sid, feedThreads, pairs, marked) && soleClient(sid, feedThreads, pairs) === projectId
198}
199
200/**
201 * What becomes of a link the bridge made by itself, by this sync's feed:
202 *   stays     its thread's sessions declared this client alone (`onlyClients`), or (0.5.9) the feed lists
203 *             none of them and the thread opened with this client's own [mm-link] marker
204 *   goes      something makes the thread another client's too (`declaredOther`: a row for another project,
205 *             a row OW marks `sole: false`, a marker for another client): let go on this sync (B3)
206 *   unlisted  the feed lists none of its sessions: the sync counts a miss
207 * (0.5.9) A thread that opened with this client's marker said so itself, and that does not age out of the
208 * feed's window: a link R1b moved to such a thread is not undone by misses and then taken again (a loop).
209 */
210export function autoLinkFate(projectId: string, sid: string, feedThreads: readonly FeedThread[], pairs: Pairs, marked: Readonly<Record<string, string>> = {}): 'stays' | 'goes' | 'unlisted' {
211  if (declaredOther(projectId, sid, feedThreads, pairs, marked)) return 'goes'
212  if (feedThreads.some(t => pairs[t.session_id]?.app === sid)) return 'stays'
213  return marked[sid] === projectId ? 'stays' : 'unlisted'
214}
215
216/**
217 * (0.5.9) When a thread started, as far as the bridge knows: the earliest any Claude Code session was first
218 * recorded running in it (each pair's `since`). Undefined, START-UNKNOWN, when no session of the thread has a pair,
219 * or when any of its pairs has no `since` (review of 54a0437d, M1: a pair written before 0.5.9 carries its last
220 * daily rewrite, and one the app's records gave late carries the day it was found; neither is when the thread
221 * started). ⛔ Never its last activity: typing in a thread later does not make it newer.
222 */
223export function threadStart(sid: string, pairs: Pairs): number | undefined {
224  let start: number | undefined
225  for (const p of Object.values(pairs)) {
226    if (!p || p.app !== sid) continue
227    if (typeof p.since !== 'number' || !Number.isFinite(p.since)) return undefined
228    if (start === undefined || p.since < start) start = p.since
229  }
230  return start
231}
232
233/**
234 * R1b (0.5.9, Rob 2026-10-15: "Can we have it update to attach to the most recent thread for each client?"): the
235 * thread a link the bridge made by itself moves to, when the client has a newer one. A candidate is open in the
236 * app's list, not archived, not the linked thread, linked to no client, not one the person unlinked, not one a
237 * client was moved off (`released`), not reached by sessions of more than one client (`shared`), not
238 * another client's by the feed or by its marker (`declaredOther`), and it opened with this client's own marker
239 * or its sessions declared this client alone (`onlyClients`). Its start (`threadStart`) must be known, and LATER
240 * than both the linked thread's start and its last activity (M1: whichever of them is known and later; neither
241 * known, nothing moves). The newest start wins. ⛔ A candidate whose start is unknown is never one.
242 * ⛔ (0.5.10) And only on evidence dated at or after its own start: the marker recorded then or later (`markedAt`;
243 * a marker with no time on record never counts here), or a feed row of its sessions for this client whose last
244 * activity is then or later. An old thread reopened (paired again, so it looks new) is not this client's newest
245 * thread on what its sessions did for the client before (review of 0.5.9, probe 240: X's link, pin and name landed
246 * on a thread the person was using for client Y until the feed caught up).
247 * ⛔ The newest thread, never the most recently active: typing in the old thread later never pulls the link back.
248 * ⛔ Only for a link the bridge made by itself: the caller never asks this for a link the person made.
249 */
250export function newerThreadFor(
251  projectId: string,
252  linked: string,
253  links: Readonly<Record<string, string>>,
254  feedThreads: readonly FeedThread[],
255  pairs: Pairs,
256  live: ReadonlyMap<string, AppThread>,
257  skip: {
258    unlinked?: Readonly<Record<string, unknown>>
259    released?: readonly string[]
260    marked?: Readonly<Record<string, string>>
261    /** (0.5.10) When each thread's marker was recorded, where that is known. */
262    markedAt?: Readonly<Record<string, number>>
263    shared?: ReadonlySet<string>
264  } = {},
265): string | undefined {
266  const marked = skip.marked ?? {}
267  const unlinked = skip.unlinked ?? {}
268  const released = skip.released ?? []
269  const shared = skip.shared ?? sharedThreads(feedThreads, pairs)
270  const known = [threadStart(linked, pairs), ms(live.get(linked)?.lastActivityAt)].filter((t): t is number => typeof t === 'number' && Number.isFinite(t))
271  if (!known.length) return undefined
272  const from = Math.max(...known)
273  let best: { sid: string; start: number } | undefined
274  for (const s of live.values()) {
275    const sid = s.sessionId
276    if (s.isArchived || sid === linked || ownerOf(sid, links) !== undefined) continue
277    if (sid in unlinked || released.includes(sid) || shared.has(sid)) continue
278    // ⛔ a thread whose marker names another client, or that the feed gives to another, never qualifies
279    if (declaredOther(projectId, sid, feedThreads, pairs, marked)) continue
280    const start = threadStart(sid, pairs)
281    if (start === undefined || start <= from) continue
282    // (0.5.10) this client's, on evidence dated at or after the thread's start
283    const markedAt = skip.markedAt?.[sid]
284    const byMarker = marked[sid] === projectId && typeof markedAt === 'number' && Number.isFinite(markedAt) && markedAt >= start
285    const byFeed =
286      soleClient(sid, feedThreads, pairs) === projectId &&
287      feedThreads.some(t => t.project_id === projectId && pairs[t.session_id]?.app === sid && ms(t.last_activity_at) >= start)
288    if (!byMarker && !byFeed) continue
289    if (!best || start > best.start) best = { sid, start }
290  }
291  return best?.sid
292}
293
294/**
295 * R1: the thread a client links itself to when it has none: the most recently active thread whose Claude
296 * Code sessions declared that client and no other (`onlyClients`). A thread already linked to another
297 * client is left alone, a thread the person unlinked is never linked again by itself (`unlinked`), a
298 * thread that ran sessions for more than one client, or opened with a marker for another, is never linked
299 * by itself, and a title match is never enough.
300 */
301export function autoLinkFor(
302  projectId: string,
303  links: Readonly<Record<string, string>>,
304  feedThreads: readonly FeedThread[],
305  pairs: Pairs,
306  live: ReadonlyMap<string, AppThread>,
307  unlinked: Readonly<Record<string, unknown>> = {},
308  marked: Readonly<Record<string, string>> = {},
309): string | undefined {
310  if (links[projectId]) return undefined
311  const taken = new Set(Object.values(links))
312  return matchedThreads(projectId, links, feedThreads, pairs, live).find(t => !taken.has(t.sid) && !(t.sid in unlinked) && onlyClients(projectId, t.sid, feedThreads, pairs, marked))?.sid
313}
314
315/**
316 * A client's known threads, what its row may offer (Open, Update, the thread a new one takes over from):
317 * the thread linked to it, and every thread whose sessions declared that client alone (`onlyClients`), each
318 * once, newest first.
319 * ⛔ Never a thread linked to a different client, and never one whose sessions also declared another
320 * project or that opened with a marker for another client (Opus, 2026-10-13: the Pipeline-speed thread, once
321 * its link was let go, was still offered for Update on that client's row).
322 */
323export function clientThreads(
324  projectId: string,
325  links: Readonly<Record<string, string>>,
326  feedThreads: readonly FeedThread[],
327  pairs: Pairs,
328  live: ReadonlyMap<string, AppThread>,
329  marked: Readonly<Record<string, string>> = {},
330): ClientThread[] {
331  const out = matchedThreads(projectId, links, feedThreads, pairs, live).filter(t => onlyClients(projectId, t.sid, feedThreads, pairs, marked))
332  const linked = links[projectId] ? live.get(links[projectId] as string) : undefined
333  if (linked && !linked.isArchived && ownerOf(linked.sessionId, links) === projectId) {
334    const same = out.find(t => t.sid === linked.sessionId)
335    if (same) same.source = 'linked'
336    else out.push({ sid: linked.sessionId, title: linked.title ?? '', lastAt: ms(linked.lastActivityAt), source: 'linked' })
337  }
338  return out.filter(t => Number.isFinite(t.lastAt)).sort((a, b) => b.lastAt - a.lastAt)
339}
340
341/**
342 * R1 (iii): the most recently active open thread, linked to nobody, whose title looks like this client.
343 * ⛔ Not one `other` says is another client's (`declaredOther`): a title never brings back a thread the feed
344 * or its marker gives to another client.
345 */
346export function titleSuggestion(client: string, links: Readonly<Record<string, string>>, live: ReadonlyMap<string, AppThread>, other: (sid: string) => boolean = () => false): Target | undefined {
347  const taken = new Set(Object.values(links))
348  let best: AppThread | undefined
349  for (const s of live.values()) {
350    if (s.isArchived || taken.has(s.sessionId) || !s.title || !titleLooksLike(s.title, client) || other(s.sessionId)) continue
351    if (!best || (ms(s.lastActivityAt) || 0) > (ms(best.lastActivityAt) || 0)) best = s
352  }
353  return best ? { sid: best.sessionId, title: best.title ?? '', suggested: true } : undefined
354}
355
356/**
357 * What a thread must already hold to be up to date: the kickoff that would be sent now (without Update's
358 * first line), and the package's `covers_through` and `updated_at` when the feed gives them. A new client
359 * message, another package, a package that changed: each makes it a different text.
360 */
361export function signatureOf(it: QueueItem): string {
362  const o = it.open ?? null
363  const mine = o?.state === 'held_by_me'
364  // A package is read as it was before anyone picked it up, and without how many of its drafts are left:
365  // picking it up or sending one of its drafts is not something new for the thread that has it.
366  const plain: QueueItem = mine ? { ...it, open: null } : o ? { ...it, open: { ...o, unsent: 0 } } : it
367  return [kickoffText(plain), it.coversThrough ?? '', mine ? `held:${o.newer}` : (it.packageUpdatedAt ?? '')].join('\n')
368}
369
370/**
371 * Whether a thread that was given `had` (the signature stored when Update went, or the chip's) already has
372 * what is waiting on `it` now. The same signature, or: a package the viewer holds, with nothing from the
373 * client since its drafts were written, in the thread that was given that package. The package's own
374 * `updated_at` is not asked then: while the viewer holds it, a change to it is their own doing.
375 */
376export function hasWaiting(had: string, it: QueueItem): boolean {
377  const want = signatureOf(it)
378  if (had === want) return true
379  const o = it.open ?? null
380  if (o?.state !== 'held_by_me' || o.newer > 0) return false
381  const head = (s: string): string => s.slice(0, Math.max(0, s.lastIndexOf('\n')))
382  return head(had) === head(want)
383}
384
385// ---------------------------------------------------------------- (0.5.11) Update by itself
386
387/**
388 * (0.5.11) Something new for a client goes into its thread by itself only once this bridge has seen it, unchanged,
389 * for this long (review of PR #15, F3: measured from when the bridge first saw it, `FirstSeen`, never from the feed's
390 * times: the live feed gives no time for a package's drafts, and a message's time says nothing of them).
391 */
392export const AUTO_QUIET_MS = 10 * 60_000
393/**
394 * (0.5.11, review of PR #15, F3) With client mail waiting and no reply drafts ready, it goes once this bridge first
395 * saw the oldest waiting message this long ago, unless drafts are ready sooner. OW holds its own notice 120 minutes,
396 * and Lane-2 aims to have the drafts ready in 60: the thread is not asked to build them while Lane-2 still may.
397 */
398export const AUTO_NO_DRAFTS_MS = 120 * 60_000
399
400/**
401 * (0.5.11, review of PR #15, F3) What this bridge has seen of a client on the person's own list, as the syncs had it
402 * (`mm.firstSeen`): what it has now (`sig`, a short hash of its signature), since when this bridge has seen exactly that
403 * (`at`), and since when client mail has been waiting without a break (`mail`; 0: none waits).
404 */
405export type FirstSeen = { sig: string; at: number; mail: number }
406
407/** (0.5.11, review of PR #15, F3) `FirstSeen` for a client as a sync at `at` has it, from what was seen before. */
408export function seenNow(prev: FirstSeen | undefined, it: QueueItem, sig: string, at: number): FirstSeen {
409  const kept = (t: unknown): number => (typeof t === 'number' && Number.isFinite(t) && t > 0 && t <= at ? t : 0)
410  const since = prev && prev.sig === sig ? kept(prev.at) : 0
411  const mail = it.messages > 0 ? kept(prev?.mail) || at : 0
412  return { sig, at: since || at, mail }
413}
414
415/**
416 * (0.5.11, review of PR #15, F3) Kickoff case 6: client mail waiting and no package at all, so the thread is asked
417 * to build one (prep). Sent by itself, the package that then appears is the thread's own work (`Sent.prep`).
418 */
419export const asksForPrep = (it: QueueItem): boolean => it.messages > 0 && !it.packageId && !(it.open ?? null) && it.kind !== 'ok'
420
421/**
422 * (0.5.11, Rob 10-15: "I think we should start updating threads automatically") Whether a client on the person's
423 * own list is one whose thread may be updated by itself: never one a teammate has (`withTeammate`; the founder's
424 * `theirs`), never one whose package someone else holds (`withHolder`), never one whose reply the server is
425 * checking or found still open (R8: Claim is the person's press), and never an internal project's (as no thread
426 * is started by itself for one).
427 */
428export function autoUpdatable(it: QueueItem, now: number): boolean {
429  const o = it.open ?? null
430  return it.internal !== true && !it.theirs && !withTeammate(it) && !(o && withHolder(o)) && fastState(it, now) === null
431}
432
433/**
434 * (0.5.11, Rob 10-15: "Joe's call from Second Peak Training ended a little while back... I don't understand why it
435 * hasn't already handed the new context there so the draft is ready when I open the thread up") Whether it is time
436 * to update a client's thread by itself, from what this bridge has seen of the client (`seen`, for what it has
437 * NOW: the caller gives the record of this signature only). (Review of PR #15, F3) The bridge has seen it, unchanged,
438 * for AUTO_QUIET_MS; and then no client message waits (tasks only), or reply drafts are ready (a package prepared for
439 * the person, or one they hold), or the bridge first saw the oldest waiting message AUTO_NO_DRAFTS_MS ago. Never
440 * the feed's own times.
441 */
442export function autoUpdateDue(it: QueueItem, now: number, seen: FirstSeen | undefined): boolean {
443  if (!seen || !(now - seen.at >= AUTO_QUIET_MS)) return false
444  if (it.messages === 0) return true
445  const o = it.open ?? null
446  const ready = it.kind === 'ready' || o?.state === 'prepared' || o?.state === 'held_by_me'
447  return ready || (seen.mail > 0 && now - seen.mail >= AUTO_NO_DRAFTS_MS)
448}
449
450/**
451 * (0.5.11, review of PR #15, F3) After an update that went by itself and asked the thread to build the client's
452 * package (`Sent.prep`), the package that appears is that thread's own work: not something new to send it. So it is
453 * while the client has a package now, in that thread, with no client message received since the send.
454 */
455export function ownPackage(given: Sent | undefined, sid: string, it: QueueItem): boolean {
456  if (!given?.prep || given.sid !== sid || asksForPrep(it)) return false
457  if (!it.packageId && !(it.open ?? null)) return false
458  return !((Number(it.newest?.mail) || 0) > given.at)
459}
460
461/**
462 * What to do about a client's thread, and why. ONE action per client:
463 *
464 *   start    Start thread  · no thread yet
465 *            Start thread  · last thread is from Oct 2                the thread is older than 7 business days
466 *   update   Update thread · thread from Mon, Oct 12 exists           a thread exists and has not been given this
467 *            Update thread · new since this thread was updated        the bridge updated it before, with less
468 *            Update thread · "<title>" looks like this client         no known thread, but a title matches
469 *   current  Thread updated 3 PM                                      the thread already has what is waiting now
470 *
471 * The thread is the client's linked one when it has one, else the most recently active matched one.
472 * `current` is decided by `sent`: the thread and the signature the bridge stored when Update last went.
473 * (0.5.11, Rob 10-15) A thread's length is no reason for a new one: Claude Code compacts it instead.
474 */
475export function recommend(
476  threads: readonly ClientThread[],
477  suggestion: Target | undefined,
478  now: number,
479  known: { sent?: Sent | undefined; signature?: string } = {},
480): Recommendation {
481  const own = threads.filter(t => t.source === 'linked')
482  const thread = [...(own.length ? own : threads)].sort((a, b) => b.lastAt - a.lastAt)[0]
483  if (thread) {
484    const target = { sid: thread.sid, title: thread.title, suggested: false }
485    // More than 60 days back is old whatever the weekends were: business time is only counted inside that.
486    const recent = now - thread.lastAt <= OLD_THREAD_MS && businessMsBetween(thread.lastAt, now) <= RECENT_THREAD_MS
487    if (!recent) return { state: 'start', reason: `last thread is from ${monthDay(thread.lastAt)}`, target }
488    const sent = known.sent && known.sent.sid === thread.sid ? known.sent : undefined
489    if (sent && known.signature !== undefined && sent.sig === known.signature) return { state: 'current', reason: `Thread updated ${clockText(sent.at, now)}`, target }
490    return { state: 'update', reason: sent ? 'new since this thread was updated' : `thread from ${weekdayMonthDay(thread.lastAt)} exists`, target }
491  }
492  // the title is the thread's own text: cleaned and cut before it is shown
493  if (suggestion) return { state: 'update', reason: `"${cleanTitle(suggestion.title)}" looks like this client`, target: suggestion }
494  return { state: 'start', reason: 'no thread yet', fresh: true }
495}
496
497/** What is waiting on a client, as its row shows it: `2 messages waiting, 1 task due`, or `drafts ready`. */
498export function waitingText(it: QueueItem): string {
499  const o = it.open ?? null
500  const drafts = !o ? '' : o.state === 'held_by_me' ? `${o.unsent} draft${o.unsent === 1 ? '' : 's'} not sent` : o.state === 'held_by_other' ? `drafts with ${o.by}` : o.state === 'stale' ? 'drafts out of date' : 'drafts ready'
501  // for the founder, a package that waits for his OK: that first, and who sends it once he has given it; one a
502  // teammate decides on, before its reply is due, says so instead (R2)
503  const ok = it.kind === 'ok' ? (it.theirs ? decidesText(it.theirs) : `needs your OK · ${(it.ok ?? null)?.sends ?? NOBODY} sends`) : ''
504  const parts = [ok, it.messages ? `${it.messages} message${it.messages > 1 ? 's' : ''} waiting` : '', it.tasks ? `${it.tasks} task${it.tasks > 1 ? 's' : ''} due` : '', drafts]
505  return parts.filter(Boolean).join(', ') || 'drafts ready'
506}
507
508/**
509 * R5: the one message that starts a client's reply in a thread. The Start chip carries it as its prompt and
510 * Update sends it into the existing thread, with a first line saying what is new. Seven cases, the first
511 * that holds (and, for the founder alone, an eighth between 2 and 3: a package that waits for his OK and
512 * that somebody else sends, `okKickoff`):
513 *   1. a package the person already picked up, with a draft not sent: carry on with it
514 *   2. a prepared package that is theirs to pick up
515 *   3. a package a teammate picked up: show what is waiting, run no prep, draft no second reply (and, for
516 *      the founder, a stale package a teammate has, not held: the same, in words of its own)
517 *   4. client messages waiting and a package marked out of date: pick it up and rebuild the drafts
518 *   5. client messages waiting and a prepared package older than the window: pick it up
519 *   6. client messages with no package yet
520 *   7. committed tasks only (a package that is out of date, or another owner's, changes nothing here:
521 *      the client is in the list for the task)
522 * ⛔ (0.5.11) `auto`: the message an update sent by itself carries (`autoUpdate`), which no person pressed for (with
523 *    `update`, the marker comes first: F4 below). It
524 *    never takes the package (client-reply-prep §9.1's pickup call): taken by a send nobody pressed for, OW would
525 *    show it held by the person, teammates would be refused it (409 held_by_another), and the 24-hour "nobody
526 *    picked this up" warning would stop counting it. Cases 2, 4, 5 and 6 then have words of their own (Opus's,
527 *    10-15, verbatim); 1, 3, 7 and the founder's OK are as pressed. A pressed Update's text is unchanged.
528 */
529export function kickoffText(it: QueueItem, opts: { update?: boolean; replaces?: Replaced | undefined; auto?: boolean } = {}): string {
530  const id = it.projectId
531  const who = `${it.projectName} (OW project ${id})`
532  // (R17) nothing due: the kickoff says no due time (the clause is left out, never "due Invalid Date")
533  const due = hasDue(it) ? dueText(it.dueAt) : ''
534  const replyDue = due ? `, reply due ${due}` : ''
535  const tasksDue = due ? ` due ${due}` : ''
536  const o = it.open ?? null
537  // (0.5.11, Opus 10-15, verbatim) cases 2 and 5 sent by itself: the pickup view, and no pickup call
538  const notTaken = (pkg: string): string =>
539    `[mm-link ${id}] The reply drafts for ${who} are ready in package ${pkg}. Load the client's project skill first, then follow client-reply-prep §9 to show the five-part pickup view for that package, but do not take it: no pickup call until I answer here, so the team can still pick it up. Then wait for me. Send nothing without my go.`
540  const body =
541    o?.state === 'held_by_me' && it.packageId
542      ? `[mm-link ${id}] Carry on with the reply drafts for ${who}. I already picked up package ${it.packageId.slice(0, 8)}: ${o.unsent} draft(s) not sent yet${o.newer > 0 ? `, and ${o.newer} client message(s) arrived since` : ''}. Load the client's project skill first, then follow client-reply-prep §9: read the package again, show the five-part pickup view and wait for me. Send nothing without my go.`
543      : it.packageId
544        ? opts.auto
545          ? notTaken(it.packageId.slice(0, 8))
546          : `[mm-link ${id}] Pick up the reply drafts for ${who}. Load the client's project skill first, then follow client-reply-prep §9: show the five-part pickup view for package ${it.packageId.slice(0, 8)} and wait for me. Send nothing without my go.`
547        : it.kind === 'ok' && o
548          ? okKickoff(it, o)
549        : o?.state === 'held_by_other'
550          ? `[mm-link ${id}] ${who}: ${o.by} picked up the reply drafts (package ${o.id.slice(0, 8)}) and has ${o.unsent} not sent yet. Load the client's project skill first, then show me what the client sent and what is due, and wait for me. Do not run prep and do not draft a second reply. Send nothing without my go.`
551        : o && withHolder(o)
552          ? // (the founder) a stale package a teammate has, not held: Opus's words, 10-13
553            `[mm-link ${id}] ${who}: ${o.by} has the reply drafts (package ${o.id.slice(0, 8)}), and they are marked out of date. Load the client's project skill first, then show me what the client sent and what is due, and wait for me. Do not run prep and do not draft a second reply. Send nothing without my go.`
554          : o?.state === 'stale' && it.messages > 0
555            ? opts.auto
556              ? // (0.5.11, Opus 10-15, verbatim) case 4 sent by itself
557                `[mm-link ${id}] ${who} needs a reply: ${it.messages} client message(s) waiting${replyDue}. Package ${o.id.slice(0, 8)} is marked out of date. Load the client's project skill first, then show me what the client sent, the current drafts and what is due, and wait for me. Do not take the package or rebuild the drafts until I answer here. Send nothing without my go.`
558              : `[mm-link ${id}] ${who} needs a reply: ${it.messages} client message(s) waiting${replyDue}. Load the client's project skill first, then follow client-reply-prep §9: package ${o.id.slice(0, 8)} exists but is marked out of date, so pick it up, rebuild the drafts, then show the five-part pickup view and wait for me. Send nothing without my go.`
559            : o?.state === 'prepared' && it.messages > 0
560              ? opts.auto
561                ? notTaken(o.id.slice(0, 8))
562                : `[mm-link ${id}] Pick up the reply drafts for ${who}. Load the client's project skill first, then follow client-reply-prep §9: show the five-part pickup view for package ${o.id.slice(0, 8)} and wait for me. Send nothing without my go.`
563              : it.messages > 0
564                ? opts.auto
565                  ? // (0.5.11, Opus 10-15, verbatim) case 6 sent by itself
566                    `[mm-link ${id}] ${who} needs a reply: ${it.messages} client message(s) waiting${replyDue}. Load the client's project skill first, then follow client-reply-prep: no package exists yet, so run prep (§2) to build one, then show the five-part pickup view without taking the package: no pickup call until I answer here. Then wait for me. Send nothing without my go.`
567                  : `[mm-link ${id}] ${who} needs a reply: ${it.messages} client message(s) waiting${replyDue}. Load the client's project skill first, then follow client-reply-prep §9: no package exists yet, so run prep first, then show the five-part pickup view and wait for me. Send nothing without my go.`
568                : `[mm-link ${id}] ${who} has ${it.tasks} committed task(s)${tasksDue}. Load the client's project skill first, then show me what is due and wait for me. Send nothing without my go.`
569  const news = `New since this thread last worked on this client: ${waitingText(it)}.`
570  // ⛔ (0.5.11, review of PR #15, F4) sent by itself: the marker first, at the very start of the text, as every hook
571  // that tells the person's time from the bridge's reads it (a time-tracking hook too old to read the app's own
572  // `peer` stamp counts a turn that opens with the marker as automatic). Then the first line, then the body
573  // without its own marker. A pressed Update's text is as it was.
574  const marker = `[mm-link ${id}] `
575  if (opts.update && opts.auto) return `${marker}${news}\n${body.startsWith(marker) ? body.slice(marker.length) : body}`
576  if (opts.update) return `${news}\n${body}`
577  const handover = opts.replaces ? handoverText(it, opts.replaces) : ''
578  return handover ? `${body}\n\n${handover}` : body
579}
580
581/**
582 * The kickoff for a package that waits for the founder's OK and that somebody else sends (kind `ok`): he
583 * is shown the package and what needs his OK. He does not pick it up, and nothing is drafted or sent.
584 */
585function okKickoff(it: QueueItem, o: NonNullable<QueueItem['open']>): string {
586  const who = `${it.projectName} (OW project ${it.projectId})`
587  const sends = (it.ok ?? null)?.sends ?? NOBODY
588  return `[mm-link ${it.projectId}] ${who}: reply package ${o.id.slice(0, 8)} is flagged for my OK${o.state === 'stale' ? ' and is marked out of date' : ''}, and ${sends} sends it. Load the client's project skill first, then follow client-reply-prep §9: show the five-part pickup view for that package and what needs my OK, and wait for me. Do not pick the package up and do not draft a second reply. Send nothing without my go.`
589}
590
591/** The thread a new one takes over from: the app's id for it and its own title. */
592export type Replaced = { sid: string; title: string }
593
594/**
595 * R8 (Rob, 2026-10-13): what Claim in this thread sends into the client's thread, in Rob's words: what the
596 * server's check of our reply found still open. `at` is the time of that reply. The items are the feed's,
597 * cleaned (queue.ts, checkOf).
598 */
599export function claimText(client: string, at: number, items: readonly string[], now: number): string {
600  return `Still open for ${client} after our reply of ${clockText(at, now)}: ${items.join('; ')}. Load the client's project skill first, then follow client-reply-prep §9: show me what is still open and the drafts for it, and wait for me. Send nothing without my go.`
601}
602
603/** (fix round 1, L13) A chip's title is at most this many characters. */
604export const CHIP_TITLE_MAX = 60
605/** A chip's title, cut to CHIP_TITLE_MAX characters (the last one an ellipsis when it was cut). */
606export const chipTitle = (title: string): string => (title.length > CHIP_TITLE_MAX ? `${title.slice(0, CHIP_TITLE_MAX - 1)}…` : title)
607
608/**
609 * R5 (Rob, 2026-10-13): the sentence a kickoff carries when the client already has a linked thread other than
610 * the one starting (a chip copy started after another copy, or another thread, took the client): that thread
611 * says which thread has the client, and stops.
612 */
613export function staleCopySentence(client: string): string {
614  return `If ${client} already has a thread other than this one, tell me its name and stop: do not run prep, do not link this thread.`
615}
616
617/**
618 * The kickoff of a new thread for a client (Start, a chip): 0.5.1's start kickoff, with the hand-over from the
619 * client's old thread. One case 0.5.1 never listed (R8): a stale package of the viewer's whose check left items
620 * open, with no client message and no task; its kickoff is the Claim message behind the client's marker.
621 */
622export function startKickoff(it: QueueItem, replaces: Replaced | undefined, now: number): string {
623  const fast = fastState(it, now)
624  const o = it.open ?? null
625  if (fast?.state === 'still_open' && !it.packageId && it.messages === 0 && it.tasks === 0 && !(o && withHolder(o))) {
626    const body = `[mm-link ${it.projectId}] ${claimText(it.client, fast.at, fast.items, now)}`
627    const handover = replaces ? handoverText(it, replaces) : ''
628    return handover ? `${body}\n\n${handover}` : body
629  }
630  return kickoffText(it, { replaces })
631}
632
633// ---------------------------------------------------------------- (0.5.12) Start all
634
635/**
636 * (0.5.12, Rob 10-15: no clicks at all; the app starts a thread only on the person's own words or press, so a new
637 * client's thread needs one person action) The most threads one Start all opens: the rest wait for a second press.
638 */
639export const START_ALL_MAX = 8
640
641/** (0.5.12) The Start all button, with how many clients need a new thread now. */
642export const startAllLabel = (n: number): string => `Start all (${n})`
643
644/**
645 * (0.5.12) The first message of a thread Start all opens: the new-thread kickoff (`startKickoff`) in the words that never
646 * take the package (kickoffText's `auto`, with no Update line, so the client's marker is first: cases 2, 4, 5 and 6
647 * show the drafts and make no pickup call until the person answers), with the hand-over from the client's old thread
648 * as a single Start has it. A client still open after our reply, with nothing else, opens with its Claim words, as
649 * Start has them. A single Start's own kickoff is unchanged.
650 */
651export function startAllKickoff(it: QueueItem, replaces: Replaced | undefined, now: number): string {
652  const fast = fastState(it, now)
653  const o = it.open ?? null
654  if (fast?.state === 'still_open' && !it.packageId && it.messages === 0 && it.tasks === 0 && !(o && withHolder(o))) return startKickoff(it, replaces, now)
655  return kickoffText(it, { replaces, auto: true })
656}
657
658/**
659 * (0.5.12) The one line a Start all leaves, in the band of every thread and above the list: the threads it started,
660 * the ones the app did not start and why, the ones it skipped (a thread or another start got there first), and how
661 * many still need a thread after the cap (START_ALL_MAX) for a second press.
662 */
663export function startAllSummary(started: readonly string[], failed: ReadonlyArray<{ client: string; why: string }>, skipped: ReadonlyArray<{ client: string; why: string }>, left: number): string {
664  const out: string[] = []
665  if (started.length) out.push(`Started ${started.length} thread${started.length === 1 ? '' : 's'}: ${started.join(', ')}.`)
666  if (failed.length) out.push(`Not started: ${failed.map(f => `${f.client} (${f.why})`).join('; ')}.`)
667  if (skipped.length) out.push(`Skipped: ${skipped.map(f => `${f.client} (${f.why})`).join('; ')}.`)
668  if (left > 0) out.push(`${left} more need${left === 1 ? 's' : ''} a thread: press Start all again.`)
669  return out.join(' ') || 'No client needs a new thread now.'
670}
671
672/** `local_<id>`: the app's id for a thread, which is what the session tools take. */
673export const THREAD_ID = /^local_[A-Za-z0-9_-]{1,100}$/
674
675/**
676 * The second paragraph of a kickoff that starts a new thread while the client already has one (Rob,
677 * 2026-10-12: "inspect the recent context of an existing thread for anything unreconciled before creating
678 * the new one"). The NEW thread's Claude reads the old thread's last three messages; the bridge reads
679 * nothing of it. '' when the old thread's id is not an app thread id.
680 * ⛔ The title is the thread's own text: cleaned to one line and cut at 60 before it goes in.
681 */
682export function handoverText(it: QueueItem, old: Replaced): string {
683  if (!THREAD_ID.test(old.sid)) return ''
684  // (a kickoff that shows no pickup view: tasks only, or a package a teammate holds or, stale, has)
685  const o = it.open ?? null
686  const before = it.packageId || it.kind === 'ok' || (it.messages > 0 && !(o && withHolder(o))) ? 'Before the pickup view' : 'Before showing me what is due'
687  return `This thread replaces "${cleanTitle(old.title)}" (thread ${old.sid}) for ${it.projectName}. ${before}, and without reading that thread in full: read only its last three messages with the session tools (list_events, a small limit) and list anything left open in it: a question waiting on me, something promised and not done, a draft that was not sent. Keep that list to ten lines, then carry on.`
688}
689
690/**
691 * The link that opens a thread in the app, as the app's own list gives it. ⛔ Only this exact shape, and
692 * only for the thread it names: `claude://claude.ai/epitaxy/<that thread's id>`. Anything else is no link.
693 */
694export function threadLink(thread: { sessionId: string; link?: unknown } | undefined): string | undefined {
695  if (!thread || typeof thread.link !== 'string' || !THREAD_ID.test(thread.sessionId)) return undefined
696  return thread.link === `claude://claude.ai/epitaxy/${thread.sessionId}` ? thread.link : undefined
697}
698
699/**
700 * The system's own opener on each platform: the program, and what stands before the link. The app gives a
701 * plugin no call that opens a thread, so this is the one way a button can take the person to it.
702 * ⛔ These three programs and no other. `rundll32 url.dll,FileProtocolHandler` on Windows, not `cmd /c
703 *    start`: no shell reads the link.
704 */
705export const OPENERS = {
706  mac: ['open'],
707  windows: ['rundll32', 'url.dll,FileProtocolHandler'],
708  linux: ['xdg-open'],
709} as const
710export type Platform = keyof typeof OPENERS
711
712/**
713 * Which platform this computer is, from its environment: Windows sets `OS` to `Windows_NT`; a home folder
714 * under `/Users/` is a Mac's and one under `/home/` is Linux's. Undefined when it cannot be told: nothing
715 * is started then.
716 */
717export function platformOf(env: { OS?: string | null | undefined; HOME?: string | null | undefined }): Platform | undefined {
718  if (env.OS === 'Windows_NT') return 'windows'
719  const home = typeof env.HOME === 'string' ? env.HOME : ''
720  if (home.startsWith('/Users/')) return 'mac'
721  if (home.startsWith('/home/')) return 'linux'
722  return undefined
723}
724
725/**
726 * The one command that opens a thread, as an argument array: the platform's opener, then the thread's link
727 * as its last argument, alone. Undefined when the thread has no link that is exactly its own (threadLink),
728 * or the platform is not known: nothing is started then.
729 * ⛔ Never a string for a shell, and nothing is ever joined into an argument.
730 */
731export function openCommand(thread: { sessionId: string; link?: unknown } | undefined, platform: Platform | undefined): readonly string[] | undefined {
732  const link = threadLink(thread)
733  if (!link || !platform || !Object.hasOwn(OPENERS, platform)) return undefined
734  return [...OPENERS[platform], link]
735}
736
737// ---------------------------------------------------------------- the pane
738
739export const LABEL = { update: 'Update thread', start: 'Start thread', open: 'Open thread', drafts: 'Drafts', claim: 'Claim in this thread' } as const
740export type RowButton = keyof typeof LABEL
741
742/** R8: the sentence under the list. */
743export const PANE_SENTENCE =
744  'Pins: overdue by up to 3 business days, or due within the next 24 business hours. Weekends do not count. Threads for these clients link themselves; use Update or Start on a row to pick one up.'
745
746/** A row of the Needs-you pane (the plugin's state contract holds the shape). */
747export type Row = MmRow
748
749/** A row's state, also for a row an older version of the bridge drew (it called it `action`). */
750export const rowState = (row: Row): RowState => {
751  const state = row.state ?? (row as { action?: unknown }).action
752  return state === 'update' || state === 'current' ? state : 'start'
753}
754
755/**
756 * How a row is drawn beyond its recommendation (0.5.2): `needsYou` for a row of the Needs-you list (R4 names a
757 * client there with no thread at all). (0.5.11: no context reading any more; R8's Claim shows whatever the
758 * thread's length.)
759 */
760export type RowOptions = { needsYou?: boolean }
761
762export function rowFor(it: QueueItem, rec: Recommendation, now: number, opts: RowOptions = {}): Row {
763  // a client whose drafts a teammate holds, and that is not the viewer's to act on: who to ask, and no action
764  const withMate = withTeammate(it)
765  // R8: the check of our reply left items open, and the client has a thread to claim them in
766  const fast = withMate ? null : fastState(it, now)
767  const still = fast?.state === 'still_open' && rec.target && !rec.target.suggested ? fast : null
768  // R4: on the Needs-you list, no thread of any kind, and due within the pin's window
769  const noThread = !withMate && opts.needsYou === true && rec.fresh === true && pinsNow(it, now)
770  // R8: the server is checking our reply: the row is named as its thread is
771  const checking = !withMate && fast?.state === 'checking' ? fast : null
772  const title = checking ? checkingTitle(it.client, checking.at, now) : noThread ? noThreadTitle(it, now) : still ? stillOpenTitle(it.client) : titleFor(it, now)
773  // (the founder) a package flagged for his OK that a teammate has, theirs to decide until its reply is due:
774  // the row's sentence is followed by Opus's words, R2. A row with no sentence (a prepared package) gets no
775  // line of them alone: its title and waiting text already say it (Opus, 10-13).
776  const hold = withMate ? holdSentence(it) : ''
777  const note = hold && it.theirs && it.kind === 'ok' ? `${hold} · ${MAY_NEED_OK}` : hold
778  return {
779    projectId: it.projectId, client: it.client, title, waiting: waitingText(it), state: rec.state, reason: rec.reason,
780    // no thread of any kind: the row goes in the first group, marked
781    fresh: rec.fresh === true,
782    // the thread this row's button acts on: a press acts on this one or on none
783    ...(rec.target ? { target: rec.target.sid } : {}),
784    ...(note ? { note } : {}),
785    // (the founder's alone, 0.5.1) a teammate's to see through: no action, with a sentence or without one
786    ...(withMate && it.theirs ? { theirs: true } : {}),
787    // R8: what is still open, drawn under the row, and its one action, Claim in this thread ((0.5.11, Opus 10-15)
788    // whatever the thread's length: a long thread is compacted, not replaced)
789    ...(still ? { stillOpen: [...still.items], claim: true } : {}),
790    ...(checking ? { checking: true as const } : {}),
791  }
792}
793
794/** R8: a row whose client's reply left items open (the server's check). */
795export const isStillOpenRow = (row: Row): boolean => Array.isArray(row.stillOpen) && row.stillOpen.length > 0
796
797/** A row under `With a teammate`, or (R8) under `Checking your reply`: not the viewer's to act on now. */
798const notMine = (row: Row): boolean => Boolean(row.note) || row.theirs === true || row.checking === true
799
800/**
801 * The buttons on a row: ONE action, chosen by its state, then Drafts (Rob, 2026-10-12: "it should be
802 * 'update thread', 'open thread', or 'start thread'... depending on whether you know what we should do").
803 * A row under `With a teammate` is not the viewer's to act on: Drafts alone.
804 */
805export function rowButtons(row: Row): RowButton[] {
806  if (notMine(row)) return ['drafts']
807  // R8: Claim in this thread ((0.5.11) whatever the thread's length; a row an older version drew with no Claim keeps none)
808  if (isStillOpenRow(row)) return row.claim ? ['claim', 'drafts'] : ['drafts']
809  const state = rowState(row)
810  return [state === 'current' ? 'open' : state, 'drafts']
811}
812
813/**
814 * The second line of a row: what is waiting, then the action and its reason, or when the thread was updated. A row with no action: what is waiting.
815 * `here` (0.5.5): the thread drawing the pane. A reason that says "this thread" says so only there: anywhere else it names the client's thread.
816 */
817export function rowDetail(row: Row, here?: string): string {
818  if (notMine(row) || isStillOpenRow(row)) return row.waiting
819  const state = rowState(row)
820  const reason = row.reason === UPDATED_HERE && !isHere(row, here) ? `new since ${row.client}'s thread was updated` : row.reason
821  return state === 'current' ? `${row.waiting} · ${reason}` : `${row.waiting} · ${LABEL[state]}: ${reason}`
822}
823
824/** The reason recommend gives a thread that was updated before and has something newer since. */
825const UPDATED_HERE = 'new since this thread was updated'
826/** (0.5.5) Whether the thread a row acts on is the thread drawing the pane. */
827const isHere = (row: Row, here: string | undefined): boolean => Boolean(here) && row.target === here
828/** (0.5.5) The most characters of a button's label. */
829export const LABEL_MAX = 40
830
831/**
832 * (0.5.5, Rob 10-14: in the Buy Oklahoma thread's pane, Black-Peak's row offered "Claim in this thread", as if it would
833 * claim Black-Peak there) A row's button as it is drawn in the thread `here`: `Claim in this thread` only in the thread it
834 * acts on; in any other, `Claim in <client>'s thread`, the client's name cut to keep the label within LABEL_MAX.
835 */
836export function buttonLabel(verb: RowButton, row: Row, here: string | undefined): string {
837  if (verb !== 'claim' || isHere(row, here)) return LABEL[verb]
838  const room = LABEL_MAX - "Claim in 's thread".length
839  const client = row.client.length > room ? `${row.client.slice(0, room - 1).trimEnd()}…` : row.client
840  return `Claim in ${client}'s thread`
841}
842
843/**
844 * The band's line as it is drawn: as it is beside its button, and with how to open the list where the
845 * band can carry no button. (On this build the band is raised on the terminal and desktop surfaces only,
846 * and both draw a Button.)
847 */
848export const bandWords = (text: string, hasButton: boolean): string => (hasButton ? text : `${text} · type /mm to open the list`)
849
850/** A row for a client that has no thread at all: the ones to notice first (not a client whose thread is old or long). */
851export const isNewRow = (row: Row): boolean => row.fresh ?? (rowState(row) === 'start' && !row.target)
852
853/** The mark before the number of a row with no thread yet. */
854export const NEW_MARK = '◆ '
855
856/**
857 * The list in the two groups it is drawn in (Rob, 2026-10-12: clients "that have sent a message and don't
858 * have a thread yet should be highlighted somehow in the list"): first the clients with no thread yet,
859 * then the ones in a thread. Each keeps the order it came in (soonest due first), and a group with nobody
860 * in it is not drawn. `n` is the row's number in the whole list.
861 */
862export function rowGroups(rows: readonly Row[]): Array<{ heading: string; rows: Array<{ row: Row; n: number; mark: string }> }> {
863  const fresh = rows.filter(isNewRow)
864  const threaded = rows.filter(r => !isNewRow(r))
865  const numbered = (group: readonly Row[], from: number, mark: string) => group.map((row, i) => ({ row, n: from + i + 1, mark }))
866  return [
867    { heading: `New: no thread yet (${fresh.length})`, rows: numbered(fresh, 0, NEW_MARK) },
868    { heading: `In a thread (${threaded.length})`, rows: numbered(threaded, fresh.length, '') },
869  ].filter(g => g.rows.length > 0)
870}
871
872export type Fold = 'checking' | 'teammate' | 'older'
873
874/** The groups under the list as a pane holds them. */
875export type Folds = { checking?: readonly Row[]; teammate?: readonly Row[]; older?: readonly Row[] }
876
877/**
878 * The groups under the list, in the order they are drawn: (0.5.2, R8, Opus decision 2) `Checking your reply
879 * (n)`, then `With a teammate (n)`, then `Older (n)`. Each is closed until the person opens it, and one with
880 * nobody in it is not drawn. Their rows are numbered on from the list's. Nobody in them is pinned, and the
881 * header does not count them.
882 */
883export function foldGroups(queue: { rows: readonly Row[] } & Folds): Array<{ key: Fold; heading: string; rows: Array<{ row: Row; n: number }> }> {
884  const checking = Array.isArray(queue.checking) ? queue.checking : []
885  const teammate = Array.isArray(queue.teammate) ? queue.teammate : []
886  const older = Array.isArray(queue.older) ? queue.older : []
887  const numbered = (group: readonly Row[], from: number) => group.map((row, i) => ({ row, n: from + i + 1 }))
888  const groups: Array<{ key: Fold; heading: string; rows: Array<{ row: Row; n: number }> }> = [
889    { key: 'checking', heading: `Checking your reply (${checking.length})`, rows: numbered(checking, queue.rows.length) },
890    { key: 'teammate', heading: `With a teammate (${teammate.length})`, rows: numbered(teammate, queue.rows.length + checking.length) },
891    { key: 'older', heading: `Older (${older.length})`, rows: numbered(older, queue.rows.length + checking.length + teammate.length) },
892  ]
893  return groups.filter(g => g.rows.length > 0)
894}
895
896/**
897 * The pane as plain text, one line per thing drawn: for tests and for a surface that draws no buttons. `folds` are the groups under the list, each drawn open.
898 * `here` (0.5.5): the thread drawing it, as for `rowDetail` and `buttonLabel`.
899 */
900export function paneText(rows: readonly Row[], header: string, note: string, folds: Folds = {}, here?: string): string {
901  const out = [header]
902  const draw = (row: Row, lead: string): void => {
903    out.push('', `${lead} ${row.title}`, `   ${rowDetail(row, here)}`)
904    if (row.note) out.push(`   ${row.note}`)
905    for (const item of isStillOpenRow(row) ? (row.stillOpen as string[]) : []) out.push(`   - ${item}`)
906    out.push(`   ${rowButtons(row).map(b => `[ ${buttonLabel(b, row, here)} ]`).join(' ')}`)
907  }
908  for (const group of rowGroups(rows)) {
909    out.push('', group.heading)
910    for (const { row, n, mark } of group.rows) draw(row, `${mark}${n}.`)
911  }
912  for (const group of foldGroups({ rows, ...folds })) {
913    out.push('', group.heading)
914    for (const { row, n } of group.rows) draw(row, `${n}.`)
915  }
916  out.push('', note)
917  return out.join('\n')
918}
919
920// ---------------------------------------------------------------- the band (R9)
921
922/** The pane's one line when the refresh after a press failed (Opus, 10-13, L3): until the next sync that works. Also the band's, when the last read failed (R9). */
923export const REFRESH_FAILED = 'Couldn\'t refresh the list. Showing what was last loaded.'
924
925/** The band's words for what it carries, in the order R9 sets: its first line, then the alert that is most specific. */
926export type BandFacts = {
927  /** Threads the last sync pinned. */
928  pinned: number
929  /** Whether anything on the list pins now (past, or within the next 24 business hours). */
930  pinsNow: boolean
931  /**
932   * R4: clients on the list due within that window with no thread at all, soonest first, with their due times, and
933   * (0.5.4, C3) the thread their one chip is in, as `threadTitleShort` names it, when there is one.
934   */
935  noThread: Array<{ client: string; due: string; chipIn?: string | undefined; chipHere?: boolean | undefined }>
936  /** (0.5.4, C2) The other threads that hold a chip for a client of the person's with a thread, one entry each, by name. */
937  chipsElsewhere?: string[]
938  /** (0.5.5) A later mm-bridge installed on this machine than the one this thread runs: its version. */
939  newer?: string | undefined
940  /** (0.5.7) Lines the band shows in every thread for a while: a thread started by itself, the hourly cap reached. */
941  notes?: string[]
942  /** R8: the first client still open after our reply, with a thread to claim it in. */
943  stillOpen?: string | undefined
944  /** R9: the first client whose pinned thread has an update ready to claim. */
945  update?: string | undefined
946  /** The last sync failed, or is older than STALE_SYNC_MS: never an all-clear from it. */
947  stale: boolean
948  /** (fix round 1, F6) The person's own clients listed under Older: more than 3 business days overdue. */
949  older?: number
950}
951
952/**
953 * R9 (Rob, 2026-10-13: "the band line is the alert"; and "yes band": the line always states the full truth, so an
954 * empty Pinned list can never read as all clear while someone still needs the person). Its words, verbatim:
955 *   something pinned, no client without a thread   `Needs you: <n> pinned.`
956 *   clients without a thread (R4)                  `Needs you: <n> pinned · <m> with no thread: <names>.`, and for
957 *                                                  one client, R9's open-a-thread line after it
958 *   a client still open after our reply (R8)       ` · <client> still open after your reply.` on the first line
959 *   an update ready on a pinned thread             R9's line after the first
960 *   nothing within 24 business hours, no client    `Nothing needs you in the next 24 hours.`
961 *   without a thread
962 *   the last read failed or is stale               0.5.1's refresh-failed line, and never the all-clear one
963 * (0.5.4, Rob 10-14 "yes chips", verbatim) A client's chip is in one thread only:
964 *   chips of clients with a thread, elsewhere (C2) ` · a chip is ready in <thread>` on the first line, or
965 *                                                  ` · chips ready in <n> threads` for more than one
966 *   a client with no thread whose chip exists (C3) `🔴 <client> has no thread (due <due>): its chip is in <thread>.`,
967 *                                                  in every thread, for each such client; (0.5.6) in any thread but
968 *                                                  that one, followed by ` Press Start thread in the pane to bring it here.`
969 * (0.5.5, Rob 10-14, verbatim) A later mod installed than this thread runs: ` · A newer Claude mod (<version>) is
970 * installed: quit and reopen Claude to load it.` at the end of the first line (of the last, when there is no first).
971 */
972export function bandText(f: BandFacts): string {
973  const out: string[] = []
974  const older = Number(f.older) || 0
975  const elsewhere = f.chipsElsewhere ?? []
976  let first = ''
977  if (f.noThread.length) first = `Needs you: ${f.pinned} pinned · ${f.noThread.length} with no thread: ${f.noThread.map(n => n.client).join(', ')}.`
978  else if (f.pinsNow || f.pinned > 0 || f.stillOpen) first = `Needs you: ${f.pinned} pinned.`
979  // (fix round 1, F6) never all clear while the person's own clients wait under Older
980  else if (!f.stale) first = older > 0 ? `Nothing due in the next 24 hours · ${older} older overdue.` : 'Nothing needs you in the next 24 hours.'
981  // R8, when no client without a thread comes before it
982  if (first && !f.noThread.length && f.stillOpen) first = `${first.replace(/\.$/, '')} · ${f.stillOpen} still open after your reply.`
983  // F6: the other forms say how many wait under Older
984  if (first.startsWith('Needs you:') && older > 0) first = `${first.replace(/\.$/, '')} · ${older} older overdue.`
985  // (0.5.4, C2) where the chips of the person's clients with a thread are, when not here
986  if (first.startsWith('Needs you:') && elsewhere.length) first = `${first.replace(/\.$/, '')} · ${elsewhere.length === 1 ? `a chip is ready in ${elsewhere[0]}` : `chips ready in ${elsewhere.length} threads`}.`
987  // (0.5.5) a later mod installed on this machine
988  const newer = f.newer ? `A newer Claude mod (${f.newer}) is installed: quit and reopen Claude to load it.` : ''
989  if (first && newer) first = `${first.replace(/\.$/, '')} · ${newer}`
990  if (first) out.push(first)
991  // (0.5.4, C3) each client with no thread whose one chip exists: where it is; (0.5.6) and, in any other thread
992  // than that one, that Start thread brings it here
993  for (const n of f.noThread) {
994    if (!n.chipIn) continue
995    out.push(`🔴 ${n.client} has no thread (due ${n.due}): its chip is in ${n.chipIn}.${n.chipHere ? '' : ' Press Start thread in the pane to bring it here.'}`)
996  }
997  const one = f.noThread.length === 1 ? f.noThread[0] : undefined
998  if (one && !one.chipIn) out.push(`Open a thread for ${one.client} (due ${one.due}): type /mm start ${one.client}.`)
999  else if (!f.noThread.length && !f.stillOpen && f.update) out.push(`An update is ready to claim in the ${f.update} thread.`)
1000  // (0.5.7) a thread started by itself, the hourly cap reached: for a while, in every thread
1001  for (const note of f.notes ?? []) out.push(note)
1002  if (f.stale) out.push(REFRESH_FAILED)
1003  // (with no first line, after the last one there is)
1004  if (newer && !first) {
1005    const tail = out.pop()
1006    out.push(tail ? `${tail.replace(/\.$/, '')} · ${newer}` : newer)
1007  }
1008  return out.join(' ')
1009}
1010
hooks/business-time.ts 170 lines
1// Business time for client clocks (Rob, 2026-10-12: "the weekends don't count as days towards the
2// 24-hour response time... We're only counting business days in our timelines for clients").
3//
4// Saturday and Sunday contribute zero time; nothing else changes. The clock is paused from Saturday 00:00
5// to Monday 00:00 America/New_York. No hours-of-day window and no holidays.
6//   Friday 3 PM + 24 h   = Monday 3 PM (9 h Friday + 15 h Monday)
7//   Saturday 10 AM + 24 h = the clock runs Monday 00:00 to Tuesday 00:00, read as Monday 11:59 PM
8//
9// Pure: Intl.DateTimeFormat and arithmetic only (a hooks module has no Node and no DOM), no imports.
10// A plugin cannot import from another repo, so this is the plugin's own copy of the business-time module
11// the onboarding-wizard server uses (src/lib/business-time.ts there). Keep the logic and the test vectors
12// (business-time.test.ts) the same in both.
13
14export const BUSINESS_TZ = 'America/New_York'
15/** ISO weekdays that never count: 6 = Saturday, 7 = Sunday. */
16export const BUSINESS_REST_DAYS: ReadonlySet<number> = new Set([6, 7])
17
18const HOUR = 3_600_000
19
20type Parts = { y: number; m: number; d: number; h: number; mi: number; s: number }
21type Day = { start: number; end: number; rest: boolean }
22
23const FMT = new Map<string, Intl.DateTimeFormat>()
24function fmt(tz: string): Intl.DateTimeFormat {
25  let f = FMT.get(tz)
26  if (!f) {
27    f = new Intl.DateTimeFormat('en-US', {
28      timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
29    })
30    FMT.set(tz, f)
31  }
32  return f
33}
34
35function localParts(ms: number, tz: string): Parts {
36  const p = fmt(tz).formatToParts(new Date(ms))
37  const g = (t: Intl.DateTimeFormatPartTypes): number => Number(p.find(x => x.type === t)?.value)
38  return { y: g('year'), m: g('month'), d: g('day'), h: g('hour'), mi: g('minute'), s: g('second') }
39}
40
41/** The instant local 00:00 begins on calendar date (y, m, d) in tz. Exact on DST change days. */
42function localMidnight(y: number, m: number, d: number, tz: string): number {
43  const guess = Date.UTC(y, m - 1, d) // 00:00 UTC of that date
44  const want = new Date(guess)
45  // every real UTC offset is inside this range
46  for (let off = -14; off <= 14; off++) {
47    const c = guess + off * HOUR
48    const a = localParts(c, tz)
49    const b = localParts(c - 1, tz)
50    if (a.y === want.getUTCFullYear() && a.m === want.getUTCMonth() + 1 && a.d === want.getUTCDate() && a.h === 0 && a.mi === 0 && a.s === 0 && b.d !== a.d) return c
51  }
52  // half-hour zones: fall back to a minute walk
53  for (let c = guess - 14 * HOUR; c <= guess + 14 * HOUR; c += 60_000) {
54    const a = localParts(c, tz)
55    const b = localParts(c - 1, tz)
56    if (a.d === want.getUTCDate() && a.h === 0 && a.mi === 0 && b.d !== a.d) return c
57  }
58  return guess
59}
60
61function isoDow(y: number, m: number, d: number): number {
62  const js = new Date(Date.UTC(y, m - 1, d, 12)).getUTCDay()
63  return js === 0 ? 7 : js
64}
65
66/** [start, end) of the local calendar day containing ms, plus whether it is a rest day. */
67function dayOf(ms: number, tz: string, rest: ReadonlySet<number>): Day {
68  const p = localParts(ms, tz)
69  const start = localMidnight(p.y, p.m, p.d, tz)
70  const n = new Date(Date.UTC(p.y, p.m - 1, p.d + 1))
71  const end = localMidnight(n.getUTCFullYear(), n.getUTCMonth() + 1, n.getUTCDate(), tz)
72  return { start, end, rest: rest.has(isoDow(p.y, p.m, p.d)) }
73}
74
75/** True when `ms` falls on a business day (Monday to Friday in tz). False when `ms` is not finite. */
76export function isBusinessTime(ms: number, tz: string = BUSINESS_TZ, rest: ReadonlySet<number> = BUSINESS_REST_DAYS): boolean {
77  if (!Number.isFinite(ms)) return false
78  return !dayOf(ms, tz, rest).rest
79}
80
81/** `ms` itself on a business day; otherwise 00:00 local of the next business day. NaN when `ms` is not finite. */
82export function nextBusinessStart(ms: number, tz: string = BUSINESS_TZ, rest: ReadonlySet<number> = BUSINESS_REST_DAYS): number {
83  if (!Number.isFinite(ms)) return NaN
84  let t = ms
85  for (let i = 0; i < 8; i++) {
86    const d = dayOf(t, tz, rest)
87    if (!d.rest) return t
88    t = d.end
89  }
90  return t
91}
92
93/** Elapsed ms from `fromMs` to `toMs` with rest days contributing zero. 0 when toMs <= fromMs or either is not finite. */
94export function businessMsBetween(fromMs: number, toMs: number, tz: string = BUSINESS_TZ, rest: ReadonlySet<number> = BUSINESS_REST_DAYS): number {
95  if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) return 0
96  let total = 0
97  let t = fromMs
98  while (t < toMs) {
99    const d = dayOf(t, tz, rest)
100    const stop = Math.min(d.end, toMs)
101    if (!d.rest) total += stop - t
102    t = stop
103  }
104  return total
105}
106
107/**
108 * The instant at which `durationMs` of business time has elapsed since `startMs`. A start on a rest day
109 * begins at 00:00 local of the next business day. Exact inverse of businessMsBetween from
110 * nextBusinessStart(startMs). NaN when either argument is not finite.
111 */
112export function addBusinessMs(startMs: number, durationMs: number, tz: string = BUSINESS_TZ, rest: ReadonlySet<number> = BUSINESS_REST_DAYS): number {
113  if (!Number.isFinite(startMs) || !Number.isFinite(durationMs)) return NaN
114  let t = nextBusinessStart(startMs, tz, rest)
115  let left = Math.max(0, durationMs)
116  for (let i = 0; i < 4000; i++) {
117    const d = dayOf(t, tz, rest)
118    if (d.rest) {
119      t = d.end
120      continue
121    }
122    const room = d.end - t
123    if (left < room) return t + left // strictly inside this business day
124    if (left === room) return d.end // lands exactly on the day's end (Monday 24:00 = Tuesday 00:00)
125    left -= room
126    t = d.end
127  }
128  return t
129}
130
131/** Whole business days elapsed: floor(businessMsBetween / 24 h). For "Nd overdue", "Nd past", "waiting Nd". */
132export function businessDaysBetween(fromMs: number, toMs: number, tz: string = BUSINESS_TZ, rest: ReadonlySet<number> = BUSINESS_REST_DAYS): number {
133  return Math.floor(businessMsBetween(fromMs, toMs, tz, rest) / (24 * HOUR))
134}
135
136/**
137 * The latest instant t with businessMsBetween(t, endMs) === durationMs. For look-back windows
138 * ("the last 48 business hours"). NaN when either argument is not finite.
139 */
140export function subtractBusinessMs(endMs: number, durationMs: number, tz: string = BUSINESS_TZ, rest: ReadonlySet<number> = BUSINESS_REST_DAYS): number {
141  if (!Number.isFinite(endMs) || !Number.isFinite(durationMs)) return NaN
142  let t = endMs
143  let left = Math.max(0, durationMs)
144  for (let i = 0; i < 4000; i++) {
145    const d = dayOf(t - 1, tz, rest) // the day that contains the instant just before t
146    if (d.rest) {
147      t = d.start
148      continue
149    }
150    const room = t - d.start
151    if (left <= room) return t - left
152    left -= room
153    t = d.start
154  }
155  return t
156}
157
158/**
159 * A deadline a person reads: addBusinessMs, except that a result landing exactly on local midnight
160 * (a weekend arrival, or a duration that ends with the business day) is given as 23:59 of the business
161 * day that just ended, so no surface prints "Tue 12:00 AM" or "Sat 12:00 AM". NaN when either argument
162 * is not finite.
163 */
164export function businessDeadline(startMs: number, durationMs: number, tz: string = BUSINESS_TZ, rest: ReadonlySet<number> = BUSINESS_REST_DAYS): number {
165  const due = addBusinessMs(startMs, durationMs, tz, rest)
166  if (!Number.isFinite(due)) return NaN
167  const p = localParts(due, tz)
168  return p.h === 0 && p.mi === 0 && p.s === 0 && due > startMs ? due - 60_000 : due
169}
170
types/index.d.ts 94 lines
1export type MmMarkdown = string
2
3/**
4 * The client part of the pane, drawn above the list: a client's prepared drafts when there is a package,
5 * else the messages waiting on our reply and the tasks due. `heading` is `This thread: <client>` when it
6 * is the client of the thread the pane is in, and empty for another client's (shown from its row's Drafts
7 * button). An empty `text` draws no client part.
8 */
9export type MmView = { text: MmMarkdown; heading: string }
10
11/** One client on the Needs-you pane: what is due, what is waiting, and the one thing to do about its thread. */
12export type MmRow = {
13  projectId: string
14  client: string
15  /** The thread name the bridge would give it: what is due and when. */
16  title: string
17  waiting: string
18  /**
19   * `start`: a new thread (none yet, or the existing one is old or long). `update`: the thread exists and
20   * has not been given what is waiting now. `current`: it already has it.
21   */
22  state: 'start' | 'update' | 'current'
23  /** Why, in a few words. For `current`, the whole phrase: when the thread was updated. */
24  reason: string
25  /** True when the client has no thread of any kind: the row is drawn in the first group, marked. Absent on a row an older version drew. */
26  fresh?: boolean
27  /** The app thread the row's button acts on, as this row was drawn. A press acts on this thread or on none. */
28  target?: string
29  /**
30   * Set on a row under `With a teammate`: who holds the client's reply drafts, and to ask them before
31   * writing a second reply. Such a row is not the viewer's to act on: it carries this sentence and the
32   * Drafts button, and no action. For the founder, on a package flagged for his OK that a teammate holds,
33   * the sentence is followed by ` · may need your OK`.
34   */
35  note?: string
36  /**
37   * For the founder alone: true on a row under `With a teammate` whose client's package a teammate sends
38   * and sees through (0.5.1). Like a row with a `note`, it has the Drafts button and no action, sentence or not.
39   */
40  theirs?: boolean
41  /**
42   * (0.5.2, R8) What the server's check of our reply to the client found still open: drawn under the row. A
43   * row with these is named `🟡 <client> · still open after your reply`.
44   */
45  stillOpen?: string[]
46  /** (0.5.2, R8) With `stillOpen`: true when the row's one action is Claim in this thread (its thread is under 60% of its context). */
47  claim?: boolean
48  /** (0.5.2, R8) The server is checking our reply to this client: the row is under `Checking your reply`, named as its thread is, with Drafts alone. */
49  checking?: true
50}
51
52/**
53 * `rows` are the clients that need the viewer now. `teammate` and `older` are the two groups under them,
54 * closed until the person opens one: clients whose drafts a teammate holds, and clients whose only items
55 * are more than 3 business days overdue. Neither is pinned or counted in the header.
56 */
57export type MmQueue = {
58  header: string
59  rows: MmRow[]
60  note: string
61  teammate?: MmRow[]
62  older?: MmRow[]
63  /** (0.5.2, R8) Clients whose reply the server is checking: a closed group `Checking your reply (n)`, never pinned or counted. */
64  checking?: MmRow[]
65  /**
66   * One line under the header when the refresh after a press failed: the list shown is the one last loaded.
67   * Gone with the next list drawn from a sync that worked. `warnedAt` is when it failed.
68   */
69  warning?: string
70  warnedAt?: number
71  /** (0.5.4) The sync the list was drawn from: a thread drawn from an older one than the store holds draws it again. */
72  syncedAt?: number
73}
74
75/** Which of the two groups under the list the person has opened. */
76export type MmFolds = { teammate?: boolean; older?: boolean; checking?: boolean }
77
78/** What the last button press did, shown above the list. */
79export type MmNotice = { text: string; at: number }
80
81/**
82 * The one line above the prompt: how many clients need the person, and this thread's client. An empty
83 * `text` draws nothing. `drafts` is true in a thread that is a waiting client's: the line then carries a
84 * second button that opens that client's pane.
85 */
86/** `at` (0.5.4): the sync the line was worked out from. `startAll` (0.5.12): the Start all button, with how many clients need a new thread. */
87export type MmBand = { text: string; drafts?: boolean; at?: number; startAll?: number }
88
89declare module 'claude-code' {
90  interface PluginState {
91    'mm-bridge': { view: MmView; queue: MmQueue; notice: MmNotice; band: MmBand; folds: MmFolds }
92  }
93}
94