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…

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).
| Plugin | What it does |
|---|---|
mm-hooks | Keeps 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-bridge | It 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. |
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.
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.
hooks/register.tsx 5147 lines1import 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 longerhooks/queue.ts 1048 lines1// 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
1048hooks/threads.ts 1010 lines1// 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}
1010hooks/business-time.ts 170 lines1// 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}
170types/index.d.ts 94 lines1export 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