@leftoff/cli 0.0.0-stage → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/CHANGELOG.md +553 -0
  3. package/LICENSE.md +9 -0
  4. package/README.md +28 -2
  5. package/assets/sample-transcript.jsonl +12 -0
  6. package/bin/checkpoints.js +7 -0
  7. package/bin/leftoff.js +585 -0
  8. package/hooks/README.md +22 -0
  9. package/hooks/settings-hooks.example.json +82 -0
  10. package/mod/.claude-plugin/plugin.json +15 -0
  11. package/mod/hooks/core.js +1397 -0
  12. package/mod/hooks/hooks.json +4 -0
  13. package/mod/hooks/register.js +1006 -0
  14. package/package.json +45 -4
  15. package/templates/agent-feed-worker/README.md +45 -0
  16. package/templates/agent-feed-worker/src/index.js +69 -0
  17. package/templates/agent-feed-worker/wrangler.toml +20 -0
  18. package/ui/app.css +1264 -0
  19. package/ui/app.js +2979 -0
  20. package/ui/apple-touch-icon.png +0 -0
  21. package/ui/changelog-md.js +64 -0
  22. package/ui/favicon-32.png +0 -0
  23. package/ui/favicon-badge-32.png +0 -0
  24. package/ui/favicon-badge.svg +1 -0
  25. package/ui/favicon.ico +0 -0
  26. package/ui/favicon.svg +1 -0
  27. package/ui/go.js +68 -0
  28. package/ui/icon-192.png +0 -0
  29. package/ui/icon-512.png +0 -0
  30. package/ui/icon-maskable-512.png +0 -0
  31. package/ui/icons.js +41 -0
  32. package/ui/index.html +55 -0
  33. package/ui/manifest.webmanifest +37 -0
  34. package/ui/needs.js +197 -0
  35. package/ui/offline.css +11 -0
  36. package/ui/offline.html +24 -0
  37. package/ui/pair.html +31 -0
  38. package/ui/picker.js +294 -0
  39. package/ui/relay-client.js +187 -0
  40. package/ui/relay-crypto.js +155 -0
  41. package/ui/relay-pair.js +48 -0
  42. package/ui/repos.js +167 -0
  43. package/ui/settings-nav.js +23 -0
  44. package/ui/summary-examples.js +66 -0
  45. package/ui/sw.js +34 -0
  46. package/ui/theme.js +41 -0
  47. package/ui/vendor/LUCIDE-LICENSE.txt +45 -0
@@ -0,0 +1,1006 @@
1
+ // Leftoff mod (Leftoff was called Checkpoints before v0.4.5): the Claude Code
2
+ // side of the Leftoff system.
3
+ //
4
+ // It posts session lifecycle and activity to the local daemon
5
+ // (http://127.0.0.1:7429), builds checkpoints with $.model.complete when the
6
+ // daemon says one is due, adds /cp, /cp-name and /leftoff (and /checkpoints, its old name), and draws
7
+ // a pane listing every open session. When the daemon is down it spools events
8
+ // to ~/checkpoints/.spool, keeps a session record in $.store, tracks idle
9
+ // itself and writes checkpoint markdown straight into the folder.
10
+ //
11
+ // Static-analysis rules this file follows (claude plugin validate): every mods
12
+ // API call is written in full ($.ns.method), $ is only passed to functions
13
+ // declared at the top level of this file, event names are string literals, and
14
+ // each event is registered once per matcher.
15
+ import {
16
+ DEFAULT_CONFIG, DEFAULT_PORT, mergeConfig, resolveMachine, applyEvent, checkpointDue, buildCheckpointPrompt,
17
+ parseCheckpointReply, heuristicCheckpoint, renderCheckpointMd, checkpointId, checkpointFileName,
18
+ sessionFolderName, id8, clip, toIso, resumeCommand, repoName,
19
+ shortId, formatTabTitle, parseTabTitle, detectTerminal, parseProcLine, trailingQuestion,
20
+ parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
21
+ listHintFrom, extractChecklists, parseTodoArgs, parseDoneArgs,
22
+ } from './core.js'
23
+
24
+ const PANE_ID = 'checkpoints'
25
+ const POST_TIMEOUT_MS = 800
26
+ const END_TIMEOUT_MS = 1000
27
+ const MODEL_TIMEOUT_MS = 20000
28
+
29
+ // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
30
+ const MOD_VERSION = '0.6.2' // keep in step with .claude-plugin/plugin.json
31
+
32
+ const st = {
33
+ internal: false,
34
+ ready: false,
35
+ sessionId: null,
36
+ root: null,
37
+ token: null,
38
+ port: DEFAULT_PORT,
39
+ cfg: DEFAULT_CONFIG,
40
+ record: null, // local mirror of the daemon's session record, kept with the shared applyEvent
41
+ daemonUp: null,
42
+ heartbeat: null,
43
+ paneTimer: null,
44
+ paneRows: [],
45
+ paneNote: '',
46
+ checkpointing: false,
47
+ lastCheckpoint: null,
48
+ nameCommand: 'cp-name',
49
+ commands: null,
50
+ modHealthSent: false,
51
+ machine: 'unknown',
52
+ proc: null, // { pid, tty, pidStartedAt } of this Claude Code process
53
+ term: null, // detectTerminal() of this session's environment
54
+ titleOff: false, // CLAUDE_CODE_DISABLE_TERMINAL_TITLE: Claude Code draws no title, so the mod writes OSC 2 itself
55
+ tabTitle: null, // the tab title last applied
56
+ tabMarker: false, // v0.5.5: the daemon says Go needs "cp:<id>" in this tab's title
57
+ paneSel: 0,
58
+ paneRepo: null, // the pane's repo filter (f)
59
+ paneAll: [],
60
+ hello: null, // what session.opened said (folder, repo, model, surface, process, terminal), to say again when asked
61
+ helloAt: 0,
62
+ asks: {}, // board requests this session has open: id -> { tool, input, done }
63
+ replyLoop: false, // the long-poll for board replies is running
64
+ listSig: null, // v0.5.1: the signature of this session's lists Claude last heard about
65
+ listPending: false, // a list was saved or ticked here: tell Claude with the next prompt
66
+ paneNeeds: 0, // sessions that need you (the pane's top line)
67
+ }
68
+
69
+ // Millisecond precision: the daemon dedupes events per (sessionId, type, at).
70
+ function nowIso() { return new Date().toISOString() }
71
+
72
+ function rand() {
73
+ const a = new Uint32Array(1)
74
+ crypto.getRandomValues(a)
75
+ return a[0].toString(16).padStart(8, '0')
76
+ }
77
+
78
+ function raceTimeout(promise, msec, $) {
79
+ return Promise.race([
80
+ promise,
81
+ new Promise((resolve) => { $.clock.after(msec, () => resolve({ timedOut: true })) }),
82
+ ])
83
+ }
84
+
85
+ async function api($, method, path, body, timeoutMs) {
86
+ const url = 'http://127.0.0.1:' + st.port + path
87
+ const init = { method, headers: { 'content-type': 'application/json', authorization: 'Bearer ' + (st.token || '') } }
88
+ if (body !== undefined) init.body = JSON.stringify(body)
89
+ try {
90
+ const r = await raceTimeout($.http.fetch(url, init), timeoutMs || POST_TIMEOUT_MS, $)
91
+ if (r.timedOut) { st.daemonUp = false; return null }
92
+ st.daemonUp = r.status !== 401 && r.status < 500
93
+ let json = null
94
+ try { json = JSON.parse(r.text) } catch {}
95
+ return { status: r.status, ok: r.ok, json }
96
+ } catch {
97
+ st.daemonUp = false
98
+ return null
99
+ }
100
+ }
101
+
102
+ // Every event goes through here: post, or spool a unique file (fs.write isn't atomic).
103
+ async function sendOrSpool($, type, data, timeoutMs) {
104
+ if (st.internal || !st.sessionId) return null
105
+ const ev = { v: 1, type, sessionId: st.sessionId, at: nowIso(), capture: 'mod', machine: st.machine, data: data || {} }
106
+ st.record = applyEvent(st.record || { id: st.sessionId, turns: 0, turnsSinceCheckpoint: 0, backgroundTasks: 0 }, ev)
107
+ const r = await api($, 'POST', '/v1/events', { events: [ev] }, timeoutMs)
108
+ if (r && r.ok) {
109
+ const j = r.json || {}
110
+ if (j.session && j.session.id === st.sessionId && j.session.title) st.daemonTitle = j.session.title
111
+ // v0.5.5: the daemon says when Go needs the "cp:<id>" marker in this tab's title
112
+ if (j.session && j.session.id === st.sessionId && typeof j.session.tabMarker === 'boolean') st.tabMarker = j.session.tabMarker
113
+ if (j.hello && type !== 'session.opened' && type !== 'session.ended') await sayHelloAgain($)
114
+ return j
115
+ }
116
+ try {
117
+ await $.fs.write(st.root + '/.spool/' + st.machine + '/' + st.sessionId + '/' + Date.now() + '-' + rand() + '.json', JSON.stringify({ events: [ev] }) + '\n')
118
+ } catch {}
119
+ try {
120
+ await $.store.set('session:' + st.sessionId, {
121
+ id: st.sessionId, title: st.record.title || null, cwd: st.record.cwd || null, repo: st.record.repo || null,
122
+ lastActivityAt: st.record.lastActivityAt || null, lastHeartbeatAt: nowIso(), endedAt: st.record.endedAt || null,
123
+ next: st.lastCheckpoint ? st.lastCheckpoint.next : null,
124
+ })
125
+ } catch {}
126
+ return null
127
+ }
128
+
129
+ // v0.4.8: the daemon restarted since this session said hello (a restart used
130
+ // to forget a session that had no turns yet), so it doesn't know this
131
+ // session's folder, process or terminal. Say it again (at most every 5 minutes).
132
+ async function sayHelloAgain($) {
133
+ if (!st.hello || Date.now() - st.helloAt < 300000) return
134
+ st.helloAt = Date.now()
135
+ await sendOrSpool($, 'session.opened', { ...st.hello, again: true })
136
+ }
137
+
138
+ async function isInternal($) {
139
+ // Leftoff's own claude runs (the summarizer's claude -p, claude plugin …) carry this.
140
+ return !!((await $.env.get('LEFTOFF_INTERNAL')) || (await $.env.get('CHECKPOINTS_INTERNAL')))
141
+ }
142
+
143
+ async function loadSetup($) {
144
+ const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
145
+ st.root = ((await $.env.get('LEFTOFF_DIR')) || (await $.env.get('CHECKPOINTS_DIR')) || home + '/checkpoints').replace(/\/+$/, '')
146
+ try { st.token = String(await $.fs.read(st.root + '/.secret/token')).trim() } catch { st.token = null }
147
+ try {
148
+ st.cfg = mergeConfig(DEFAULT_CONFIG, JSON.parse(String(await $.fs.read(st.root + '/config.json'))))
149
+ st.port = Number(st.cfg.port) || DEFAULT_PORT
150
+ } catch { st.cfg = DEFAULT_CONFIG }
151
+ const port = await $.env.get('CHECKPOINTS_PORT')
152
+ if (port) st.port = Number(port)
153
+ // Same rule as the daemon: CHECKPOINTS_MACHINE > config machineNames[hostname] > config machine > hostname
154
+ let hostname = ''
155
+ try { const r = await $.process.run(['hostname']); if (r.exitCode === 0) hostname = String(r.stdout || '').trim() } catch {}
156
+ if (!hostname) hostname = (await $.env.get('HOSTNAME')) || (await $.env.get('COMPUTERNAME')) || ''
157
+ st.machine = resolveMachine({ env: await $.env.get('CHECKPOINTS_MACHINE'), hostname, cfg: st.cfg })
158
+ }
159
+
160
+ async function readRepo($, cwd) {
161
+ let repo = null
162
+ try {
163
+ const r = await $.session.repo()
164
+ if (r) repo = { root: r.root, name: r.root ? r.root.split('/').filter(Boolean).pop() : r.name }
165
+ } catch {}
166
+ if (repo) {
167
+ try {
168
+ const out = await $.process.run(['git', '-C', cwd, 'symbolic-ref', '--short', '-q', 'HEAD'])
169
+ const b = String(out.stdout || '').trim()
170
+ if (out.exitCode === 0 && b) repo.branch = b // empty on a detached HEAD
171
+ } catch {}
172
+ }
173
+ return repo
174
+ }
175
+
176
+ async function heartbeat($) {
177
+ if (st.internal || !st.sessionId) return
178
+ const res = await sendOrSpool($, 'heartbeat', { inTurn: !!(st.record && st.record.inTurn) })
179
+ if (res && !st.modHealthSent) await reportModHealth($)
180
+ if (res && st.daemonTitle) await noteTitle($, st.daemonTitle)
181
+ else if (res && st.tabTitle && !(st.record && st.record.inTurn)) await applyTabTitle($, null) // the marker came or went
182
+ if (res && res.checkpointDue && res.checkpointDue.sessionId === st.sessionId) {
183
+ await makeCheckpoint($, res.checkpointDue.reason, null)
184
+ } else if (!res && st.record) {
185
+ // Daemon down: the same dormancy rules, run locally (§4).
186
+ const reason = checkpointDue(st.record, Date.now(), st.cfg)
187
+ if (reason) await makeCheckpoint($, reason, null)
188
+ }
189
+ }
190
+
191
+ // The commands, in the order they're tried. Claude Code 2.1.295's built-ins
192
+ // own /checkpoint (an alias of /rewind) and /name (an alias of /rename), so
193
+ // ours share a /cp prefix; each command lists fallbacks in case a later
194
+ // release takes a name. -> st.commands: [{ key, want, name, ok, error }]
195
+ const COMMANDS = [
196
+ { key: 'checkpoint', names: ['cp', 'cp-save'], description: 'Leftoff: save a checkpoint of this session now (--short / --long for this one)', argumentHint: '[--short|--long] [note]', immediate: true },
197
+ { key: 'name', names: ['cp-name'], description: 'Leftoff: name this session (runs /rename, tells the board)', argumentHint: '<title>', immediate: false },
198
+ { key: 'pane', names: ['leftoff'], description: 'Leftoff: every open session and its status (/leftoff open: the board)', argumentHint: '[open]', immediate: true },
199
+ // The pane's pre-0.4.5 name, kept so the habit keeps working.
200
+ { key: 'pane-old', names: ['checkpoints'], description: 'Leftoff (old name): same as /leftoff', argumentHint: '[open]', immediate: true },
201
+ // v0.5.0: one recap across your sessions; same as `leftoff catchup`.
202
+ { key: 'catchup', names: ['cp-catchup', 'leftoff-catchup'], description: 'Leftoff: catch me up — what got done, where each session stopped, what\'s waiting on you', argumentHint: '[24h|3d|workday|friday] [repo…|here|all]', immediate: true },
203
+ // v0.5.1: save the checklist Claude just wrote; tick its items.
204
+ { key: 'todo', names: ['cp-todo', 'leftoff-todo'], description: 'Leftoff: save the checklist Claude just wrote as a list you can tick off (board, phone, or here)', argumentHint: '[name] [--pick [N]]', immediate: true },
205
+ { key: 'done', names: ['cp-done', 'leftoff-done'], description: 'Leftoff: tick items on this session\'s list (/cp-done 3, /cp-done 2 4, /cp-done undo 3)', argumentHint: '<n…> [in <list>]', immediate: true },
206
+ ]
207
+
208
+ async function registerCommands($) {
209
+ const results = []
210
+ for (const c of COMMANDS) {
211
+ const r = { key: c.key, want: c.names[0], name: null, ok: false, error: null, tried: [] }
212
+ for (const name of c.names) {
213
+ r.tried.push(name)
214
+ try {
215
+ await $.command.register({ name, description: c.description, argumentHint: c.argumentHint, immediate: c.immediate })
216
+ r.name = name
217
+ r.ok = true
218
+ r.error = null
219
+ break
220
+ } catch (err) {
221
+ r.error = String((err && err.message) || err).slice(0, 200)
222
+ }
223
+ }
224
+ results.push(r)
225
+ }
226
+ st.commands = results
227
+ if (results.some((x) => x.key === 'name' && x.ok)) st.nameCommand = results.find((x) => x.key === 'name').name
228
+ const failed = results.filter((x) => !x.ok)
229
+ const moved = results.filter((x) => x.ok && x.name !== x.want)
230
+ try {
231
+ if (failed.length) $.ui.log('commands failed to register: ' + failed.map((x) => '/' + x.want + ' (' + x.error + ')').join('; '))
232
+ if (moved.length) $.ui.log('using ' + moved.map((x) => '/' + x.name + ' instead of /' + x.want).join(', '))
233
+ } catch {}
234
+ st.modHealthSent = false
235
+ await reportModHealth($)
236
+ return results
237
+ }
238
+
239
+ // Tell the daemon which commands this session got, so `leftoff doctor`
240
+ // and the board can show a failed registration. Best effort: if the daemon is
241
+ // down, the heartbeat tries again.
242
+ async function reportModHealth($) {
243
+ if (st.modHealthSent || !st.commands || !st.sessionId) return
244
+ const body = { sessionId: st.sessionId, machine: st.machine, modVersion: MOD_VERSION, at: nowIso(), commands: st.commands.map((c) => ({ key: c.key, want: c.want, name: c.name, ok: c.ok, error: c.error })) }
245
+ const r = await api($, 'POST', '/v1/mod/health', body, 1500)
246
+ if (r && r.ok) st.modHealthSent = true
247
+ }
248
+
249
+ // /cp [--short|--long] [note]: the flag picks the length for this one checkpoint.
250
+ async function checkpointCommand($, args) {
251
+ const a = parseCheckpointArgs(args)
252
+ const cp = await makeCheckpoint($, 'manual', a.note, a.length)
253
+ if (!cp) return { text: 'Leftoff: a checkpoint is already being made.' }
254
+ return {}
255
+ }
256
+
257
+ // ------------------------------------------------------------ lists (v0.5.1)
258
+
259
+ // This session's saved lists, when they changed since Claude last heard
260
+ // (the daemon's signature moved) -> the context text, else null. One local
261
+ // GET per prompt; the daemon reads the lists from a 2-second cache.
262
+ async function listContextFor($) {
263
+ if (st.internal || !st.sessionId || st.daemonUp === false && !st.listPending) return null
264
+ const r = await api($, 'GET', '/v1/sessions/' + encodeURIComponent(st.sessionId) + '/lists', undefined, 600)
265
+ if (!r || !r.ok || !r.json) return null
266
+ const sig = r.json.signature || null
267
+ if (!sig || (sig === st.listSig && !st.listPending)) { st.listPending = false; return null }
268
+ st.listSig = sig
269
+ st.listPending = false
270
+ return r.json.context || null
271
+ }
272
+
273
+ function listLines(l, max = 30) {
274
+ const out = []
275
+ for (const it of (l.items || []).slice(0, max)) out.push(' ' + String(it.n).padStart(2) + ' ' + (it.done ? '[x]' : '[ ]') + ' ' + clip(it.text, 110))
276
+ if ((l.items || []).length > max) out.push(' … ' + ((l.items || []).length - max) + ' more')
277
+ return out
278
+ }
279
+
280
+ // /cp-todo [name] [--pick [N]]: save the most recent checklist Claude wrote
281
+ // in this session (or the one picked) as a list in the folder.
282
+ async function saveTodo($, args) {
283
+ if (st.internal || !st.sessionId) return { text: 'Leftoff: not in a tracked session.' }
284
+ const a = parseTodoArgs(args)
285
+ let messages = []
286
+ try { messages = await $.session.messages() } catch {}
287
+ const found = extractChecklists(messages, { minItems: 2, fallback: st.daemonTitle || (st.record && st.record.title) || null }).reverse()
288
+ if (!found.length) return { text: 'Leftoff: no checklist from Claude in this session yet. Ask for one ("list the next steps to …"); Leftoff picks up "- [ ]" checklists and numbered or bulleted lists under a heading like "Next steps".' }
289
+ if (a.pick === true) {
290
+ const lines = ['Checklists Claude wrote in this session (newest first):', '']
291
+ for (let k = 0; k < found.length && k < 12; k++) {
292
+ const c = found[k]
293
+ lines.push(' ' + (k + 1) + '. ' + c.name + ' · ' + c.items.length + ' items')
294
+ for (const it of c.items.slice(0, 2)) lines.push(' ' + clip(it.text, 90))
295
+ }
296
+ lines.push('', 'Save one: /cp-todo --pick <n> [name]')
297
+ return { text: lines.join('\n') }
298
+ }
299
+ const c = typeof a.pick === 'number' ? found[a.pick - 1] : found[0]
300
+ if (!c) return { text: 'Leftoff: there\'s no list ' + a.pick + ' (/cp-todo --pick shows them).' }
301
+ const r = await api($, 'POST', '/v1/lists', { sessionId: st.sessionId, items: c.items, name: a.name || c.name }, 5000)
302
+ if (!r) return { text: 'Leftoff: the daemon isn\'t running, so the list wasn\'t saved. Start it with `leftoff serve`, or save it from a terminal: leftoff todo capture ' + st.sessionId + (a.name ? ' --name ' + JSON.stringify(a.name) : '') }
303
+ if (!r.ok || !r.json || !r.json.list) return { text: 'Leftoff: couldn\'t save the list' + (r.json && r.json.error ? ': ' + r.json.error.message : ' (' + r.status + ')') }
304
+ const l = r.json.list
305
+ st.listPending = true // Claude hears about it with the next prompt
306
+ return { text: ['Saved "' + l.name + '" (' + l.total + ' items) → lists/' + l.ref + '.md', ...listLines(l), '',
307
+ 'Tick items: /cp-done 3 · or just tell Claude "step 3 is done" (it runs `leftoff todo done ' + l.ref + ' 3`) · or tick them on the board / phone under Lists.'].join('\n') }
308
+ }
309
+
310
+ // /cp-done <n…|words> [in <list>], /cp-done undo <n>
311
+ async function tickTodo($, args) {
312
+ if (st.internal || !st.sessionId) return { text: 'Leftoff: not in a tracked session.' }
313
+ const a = parseDoneArgs(args)
314
+ if (!a.numbers.length && !a.text) return { text: 'Usage: /cp-done <n> [<n>…] [in <list>] · /cp-done undo <n>' }
315
+ let ref = a.list
316
+ if (!ref) {
317
+ const r = await api($, 'GET', '/v1/sessions/' + encodeURIComponent(st.sessionId) + '/lists', undefined, 3000)
318
+ if (!r) return { text: 'Leftoff: the daemon isn\'t running. From a terminal: leftoff todo done <list> ' + (a.numbers.join(' ') || a.text) }
319
+ const open = ((r.json && r.json.lists) || []).filter((l) => !l.archived)
320
+ if (!open.length) return { text: 'Leftoff: no list saved from this session yet. /cp-todo saves the checklist Claude just wrote; for another list: /cp-done 3 in <list>.' }
321
+ ref = open[0].ref // the newest one
322
+ }
323
+ const body = a.numbers.length ? { [a.undo ? 'undone' : 'done']: a.numbers } : { text: a.text, undo: a.undo }
324
+ const r = await api($, 'POST', '/v1/lists/' + encodeURIComponent(ref).replace('%2F', '/') + '/change', body, 4000)
325
+ if (!r) return { text: 'Leftoff: the daemon isn\'t running. From a terminal: leftoff todo done ' + ref + ' ' + (a.numbers.join(' ') || a.text) }
326
+ if (!r.ok || !r.json || !r.json.list) {
327
+ // a list named in the command: let the daemon resolve names, not just ids
328
+ if (a.list && r.status === 404) {
329
+ const res = await api($, 'GET', '/v1/lists/resolve?ref=' + encodeURIComponent(a.list), undefined, 3000)
330
+ if (res && res.ok && res.json && res.json.list && res.json.list.ref !== ref) return tickTodo($, (a.undo ? 'undo ' : '') + (a.numbers.join(' ') || a.text) + ' in ' + res.json.list.ref)
331
+ }
332
+ return { text: 'Leftoff: ' + ((r.json && r.json.error && r.json.error.message) || 'couldn\'t change the list (' + r.status + ')') }
333
+ }
334
+ const l = r.json.list
335
+ st.listPending = true
336
+ const hit = a.numbers.length ? l.items.filter((x) => a.numbers.includes(x.n)) : []
337
+ const line = (a.undo ? '↺ ' : '✓ ') + (hit.length ? hit.map((x) => '#' + x.n + ' ' + clip(x.text, 80)).join('; ') : 'ticked') + (a.undo ? ' (unticked)' : '') + ' · ' + l.name + ' ' + l.progress
338
+ const missing = r.json.missing && r.json.missing.length ? '\n(no item ' + r.json.missing.join(', ') + ' on that list)' : ''
339
+ return { text: line + missing + (l.done === l.total ? '\nThat\'s the whole list. 🎉' : '') }
340
+ }
341
+
342
+ // /cp-catchup [window] [repo ...|here|all] (v0.5.0): the daemon reads the
343
+ // folder and assembles the recap; with an in-session provider (auto, claude,
344
+ // anthropic) this session's own model writes the summary on top, otherwise the
345
+ // daemon's summarizer (or its no-model summary) does. Saved to catchups/ like
346
+ // the board's and the CLI's. -> { text } shown in the session.
347
+ async function makeCatchup($, args) {
348
+ if (st.internal) return { text: 'Leftoff: not in an internal session.' }
349
+ const a = parseCatchupArgs(args)
350
+ const repos = a.here ? [((st.record && st.record.repo && st.record.repo.root) || (st.record && st.record.cwd) || '')].filter(Boolean) : a.repos
351
+ const req = { since: a.since || undefined, repos }
352
+ const prep = await api($, 'POST', '/v1/catchups/prepare', req, 15000)
353
+ if (!prep) return { text: 'Leftoff: the daemon isn\'t running, so there\'s nothing to read. Start it with `leftoff serve` (or run `leftoff catchup` in a terminal; it reads the folder directly).' }
354
+ if (!prep.ok) return { text: 'Leftoff: ' + ((prep.json && prep.json.error && prep.json.error.message) || 'catch-up failed (' + prep.status + ')') }
355
+ const p = prep.json || {}
356
+ let summary
357
+ if (p.prompt) {
358
+ const r = await callModel($, p.prompt)
359
+ const bullets = r && parseBulletList(r.text, 6)
360
+ if (bullets) summary = { bullets, provider: 'claude-in-session', model: r.model || null }
361
+ }
362
+ const made = await api($, 'POST', '/v1/catchups', { ...req, to: p.window && p.window.to, summary }, summary ? 15000 : 90000)
363
+ if (!made || !made.ok) return { text: 'Leftoff: catch-up failed' + (made && made.json && made.json.error ? ': ' + made.json.error.message : '') }
364
+ return { text: formatCatchupText(made.json) }
365
+ }
366
+
367
+ function formatCatchupText(c) {
368
+ const r = c.recap || {}, w = r.window || {}
369
+ const lines = ['Catch me up · ' + (w.label || '') + ' · ' + (r.sessions || []).length + ' session' + ((r.sessions || []).length === 1 ? '' : 's') + ' in ' + (r.repos || []).length + ' repo' + ((r.repos || []).length === 1 ? '' : 's'), '']
370
+ for (const b of (c.summary && c.summary.bullets) || []) lines.push('• ' + b)
371
+ const needs = r.needsYou || []
372
+ if (needs.length) { lines.push('', 'Needs you:'); for (const n of needs.slice(0, 6)) lines.push(' ! ' + n.title + ' (' + n.repo + ') — ' + clip(n.needsYou, 140)) }
373
+ const rest = (r.sessions || []).filter((x) => !x.needsYou).slice(0, 8)
374
+ if (rest.length) { lines.push('', 'Where each one stopped:'); for (const x of rest) lines.push(' · ' + x.title + ' (' + x.repo + ', ' + x.state + ')' + (x.next ? ' — Next: ' + clip(x.next, 120) : '')) }
375
+ const open = r.stillOpen || []
376
+ if (open.length) { lines.push('', 'Still open:'); for (const l of open.slice(0, 6)) lines.push(' ☐ ' + l.name + ' (' + l.repo + ', ' + l.done + '/' + l.total + ')' + (l.next && l.next[0] ? ' — next: #' + l.next[0].n + ' ' + clip(l.next[0].text, 100) : '')) }
377
+ if (c.path) lines.push('', 'Saved ' + c.path + ' · the full page: /leftoff open → Catch me up')
378
+ return lines.join('\n')
379
+ }
380
+
381
+ // Run the CLI: `leftoff`, or `checkpoints` (its pre-0.4.5 name, still installed
382
+ // as an alias) when `leftoff` isn't on PATH yet. -> the run result, or null.
383
+ async function runCli($, args, opts) {
384
+ for (const bin of ['leftoff', 'checkpoints']) {
385
+ try {
386
+ const r = opts ? await $.process.run([bin, ...args], opts) : await $.process.run([bin, ...args])
387
+ if (r && r.exitCode !== 127) return r
388
+ } catch {}
389
+ }
390
+ return null
391
+ }
392
+
393
+ // Open the web board. `leftoff open --print` knows the token, starts the
394
+ // daemon if it's down and picks leftoff.localhost or 127.0.0.1; then the OS
395
+ // opener (macOS `open`, Linux `xdg-open`) shows it. Without the CLI on PATH it
396
+ // opens the 127.0.0.1 URL. The URL is always toasted too, so it can be clicked
397
+ // or copied when no browser opens (SSH, containers).
398
+ // /leftoff (and /checkpoints): the pane, or `open` for the web board.
399
+ async function paneCommand($, args) {
400
+ if (/^\s*(open|board|ui)\s*$/i.test(String(args || ''))) { const url = await openBoard($); return { text: 'Board: ' + url } }
401
+ if (st.paneTimer) { st.paneTimer.cancel(); st.paneTimer = null }
402
+ await refreshPane($)
403
+ let surfaces = []
404
+ try { surfaces = await $.session.surfaces() } catch {}
405
+ if (!surfaces || !surfaces.length) return { text: textList() } // VS Code extension and -p: no panes
406
+ await $.ui.open({ id: PANE_ID, title: 'Leftoff', closeOnEscape: true })
407
+ if (st.paneTimer) st.paneTimer.cancel()
408
+ st.paneTimer = $.clock.every(15000, () => { refreshPane($).catch(() => {}) })
409
+ return {}
410
+ }
411
+
412
+ async function openBoard($) {
413
+ let url = 'http://127.0.0.1:' + st.port + '/'
414
+ try {
415
+ const r = await runCli($, ['open', '--print'])
416
+ const u = String((r && r.stdout) || '').trim().split('\n').pop()
417
+ if (r && r.exitCode === 0 && /^http:\/\/[\w.[\]:-]+\/$/.test(u)) url = u
418
+ } catch {}
419
+ let opened = false
420
+ for (const opener of ['open', 'xdg-open']) {
421
+ try { const r = await $.process.run([opener, url]); if (r && r.exitCode === 0) { opened = true; break } } catch {}
422
+ }
423
+ $.ui.toast((opened ? 'Opened ' : 'Board: ') + url, { timeoutMs: 10000 })
424
+ return url
425
+ }
426
+
427
+ // ---------------------------------------------------------------- where this session runs (v0.4)
428
+
429
+ // One shell call: our parent is Claude Code's process (checked on 2.1.295:
430
+ // $.process.run's child has Claude Code as its direct parent). If a wrapper
431
+ // sits between, walk up to the first process named like claude.
432
+ const PROC_SCRIPT = 'p=$PPID; i=0; while [ "$i" -lt 4 ]; do c=$(LC_ALL=C ps -o comm= -p "$p" 2>/dev/null); case "$c" in *claude*|*Claude*) break;; esac; q=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); [ -z "$q" ] || [ "$q" -le 1 ] && { p=$PPID; break; }; p=$q; i=$((i+1)); done; [ "$i" -ge 4 ] && p=$PPID; set -- $(LC_ALL=C ps -o tty=,etime= -p "$p" 2>/dev/null); echo "$p ${1:-?} ${2:-}"'
433
+
434
+ async function probeProcess($) {
435
+ try {
436
+ const r = await $.process.run(['sh', '-c', PROC_SCRIPT], { timeoutMs: 3000 })
437
+ if (r && r.exitCode === 0) return parseProcLine(String(r.stdout || '').trim().split('\n').pop(), Date.now())
438
+ } catch {}
439
+ return null
440
+ }
441
+
442
+ // $.env.get takes literal names only (static analysis lists what a mod reads).
443
+ async function readTermEnv($) {
444
+ const env = {}
445
+ const put = (k, v) => { if (v) env[k] = String(v) }
446
+ try {
447
+ put('TERM_PROGRAM', await $.env.get('TERM_PROGRAM'))
448
+ put('TERM_PROGRAM_VERSION', await $.env.get('TERM_PROGRAM_VERSION'))
449
+ put('TERM', await $.env.get('TERM'))
450
+ put('GHOSTTY_RESOURCES_DIR', await $.env.get('GHOSTTY_RESOURCES_DIR'))
451
+ put('ITERM_SESSION_ID', await $.env.get('ITERM_SESSION_ID'))
452
+ put('WEZTERM_PANE', await $.env.get('WEZTERM_PANE'))
453
+ put('WEZTERM_UNIX_SOCKET', await $.env.get('WEZTERM_UNIX_SOCKET'))
454
+ put('KITTY_WINDOW_ID', await $.env.get('KITTY_WINDOW_ID'))
455
+ put('KITTY_LISTEN_ON', await $.env.get('KITTY_LISTEN_ON'))
456
+ put('TMUX', await $.env.get('TMUX'))
457
+ put('TMUX_PANE', await $.env.get('TMUX_PANE'))
458
+ put('TERM_SESSION_ID', await $.env.get('TERM_SESSION_ID'))
459
+ st.titleOff = !!(await $.env.get('CLAUDE_CODE_DISABLE_TERMINAL_TITLE'))
460
+ } catch {}
461
+ return env
462
+ }
463
+
464
+ // The tab title: the session's name ("<name> · cp:<id>" only when the daemon
465
+ // says Go needs the marker, v0.5.5). Claude Code draws the terminal title
466
+ // itself (✳ / ◐ + the session's name, repainted about once a second while it
467
+ // works), so a raw OSC 2 write is painted over almost at once. Instead the mod
468
+ // names the session with Claude Code's own /rename and Claude Code keeps the
469
+ // tab titled. (Claude Code's terminalTitleFromRename setting has to stay on,
470
+ // which it is by default.) With CLAUDE_CODE_DISABLE_TERMINAL_TITLE set, Claude
471
+ // Code draws nothing, so the mod writes OSC 2 to the session's tty instead.
472
+ function tabTitleOn() { return st.cfg.tabTitle !== false && !st.internal && st.surface !== 'headless' }
473
+
474
+ function desiredTabTitle(name) {
475
+ const repo = st.record ? repoName(st.record) : null
476
+ return formatTabTitle(st.cfg.tabTitleFormat, { name: name || (st.record && st.record.title) || null, id: st.sessionId, repo, marker: !!st.tabMarker })
477
+ }
478
+
479
+ async function applyTabTitle($, name, { force = false, current = null } = {}) {
480
+ if (!tabTitleOn() || !st.sessionId) return null
481
+ const title = desiredTabTitle(name)
482
+ if (current && String(current).trim() === title && !st.titleOff) { st.tabTitle = title; return null } // resumed: already named
483
+ if (!title || (!force && title === st.tabTitle)) return null
484
+ st.tabTitle = title
485
+ if (st.titleOff) {
486
+ const tty = st.proc && st.proc.tty
487
+ if (!tty) return null
488
+ try { await $.process.run(['sh', '-c', 'printf "\\033]2;%s\\007" "$1" > "$2"', 'sh', title, tty], { timeoutMs: 2000 }) } catch {}
489
+ return title
490
+ }
491
+ try { await $.command.run({ command: 'rename', args: title }) } catch { st.tabTitle = null }
492
+ return title
493
+ }
494
+
495
+ // A title the daemon or a hook payload reports: adopt it for the tab when it
496
+ // isn't our own label coming back.
497
+ async function noteTitle($, title) {
498
+ if (!title || parseTabTitle(title).id) return
499
+ if (!st.record) st.record = {}
500
+ st.record.title = title
501
+ if (!st.record.inTurn) await applyTabTitle($, title)
502
+ }
503
+
504
+ // Providers that use the session's own model. Any other provider (ollama,
505
+ // ollama-cloud, openai-compatible, heuristic) is the daemon's job: the mod hands
506
+ // the work to the daemon instead of calling $.model.complete.
507
+ const IN_SESSION_PROVIDERS = ['auto', 'claude', 'anthropic', 'api']
508
+
509
+ async function callModel($, ctx) {
510
+ const s = st.cfg.summarizer || {}
511
+ if (!IN_SESSION_PROVIDERS.includes(String(s.provider || s.mode || 'auto').toLowerCase())) return null
512
+ const model = ['haiku', 'sonnet', 'opus'].includes(s.model) || !s.model ? (s.model || 'haiku') : s.model
513
+ try {
514
+ const r = await raceTimeout($.model.complete({ model, system: ctx.system, prompt: ctx.prompt, maxTokens: ctx.maxTokens || 300 }), MODEL_TIMEOUT_MS, $)
515
+ if (!r || r.timedOut) return null
516
+ // The types say complete() resolves to the reply's text; the spec sketch
517
+ // mentions { isAnswered }. Accept both.
518
+ if (typeof r === 'string') return { text: r, model }
519
+ if (r.isAnswered === false) return null
520
+ return { text: r.text || '', model }
521
+ } catch {
522
+ return null
523
+ }
524
+ }
525
+
526
+ async function writeCheckpointDirect($, cp) {
527
+ let folder = null
528
+ const suffix = '_' + id8(st.sessionId)
529
+ const mine = st.root + '/sessions/' + st.machine
530
+ try {
531
+ const entries = await $.fs.list(mine)
532
+ const hit = entries.find((x) => x.kind === 'directory' && x.name.endsWith(suffix))
533
+ if (hit) folder = hit.name
534
+ } catch {}
535
+ if (!folder) folder = sessionFolderName({ ...st.record, id: st.sessionId, startedAt: st.record.startedAt || cp.createdAt })
536
+ for (let n = 0; n < 20; n++) {
537
+ const file = mine + '/' + folder + '/checkpoints/' + checkpointFileName(cp.createdAt, n)
538
+ let taken = false
539
+ try { taken = await $.fs.exists(file) } catch {}
540
+ if (taken) continue
541
+ const rec = { ...cp, id: checkpointId(st.sessionId, cp.createdAt, n) }
542
+ await $.fs.write(file, renderCheckpointMd(rec))
543
+ return rec
544
+ }
545
+ return null
546
+ }
547
+
548
+ // length: 'short' | 'informative' | 'long' for this checkpoint only (/cp --short,
549
+ // /cp --long); null = config summary.length.
550
+ async function makeCheckpoint($, trigger, userNote, length) {
551
+ if (st.internal || st.checkpointing || !st.sessionId) return null
552
+ st.checkpointing = true
553
+ try {
554
+ const record = { ...(st.record || {}), id: st.sessionId }
555
+ let messages = []
556
+ try { messages = await $.session.messages() } catch {}
557
+ const ctx = buildCheckpointPrompt({ session: record, previous: st.lastCheckpoint, messages, cfg: st.cfg, userNote, length })
558
+ const reply = await callModel($, ctx)
559
+ let parsed = reply ? parseCheckpointReply(reply.text) : null
560
+ let generator = reply ? { by: 'mod', model: reply.model } : null
561
+ if (!parsed) {
562
+ // No model answer: let the daemon try its own summarizer, if it's there.
563
+ const r = await api($, 'POST', '/v1/sessions/' + encodeURIComponent(st.sessionId) + '/checkpoints', { trigger, generate: true, userNote: userNote || undefined, length: ctx.length }, MODEL_TIMEOUT_MS * 3)
564
+ if (r && r.ok && r.json) return afterCheckpoint($, r.json, trigger)
565
+ parsed = heuristicCheckpoint(messages, record, { length: ctx.length })
566
+ generator = { by: 'heuristic' }
567
+ }
568
+ const cp = {
569
+ sessionId: st.sessionId, createdAt: nowIso(), trigger, machine: st.machine,
570
+ sessionTitle: record.title || null, repo: record.repo ? repoName(record) : null, branch: (record.repo && record.repo.branch) || null, cwd: record.cwd || null,
571
+ doing: parsed.doing, stoppedAt: parsed.stoppedAt, next: parsed.next, openQuestion: parsed.openQuestion || null,
572
+ userNote: userNote || null, filesTouched: ctx.filesTouched, turnsSinceLast: record.turnsSinceCheckpoint || 0,
573
+ parseOk: parsed.parseOk, generator, length: ctx.length,
574
+ decisions: parsed.decisions || [], questions: parsed.questions || [], files: parsed.files || [],
575
+ didntWork: parsed.didntWork || [], recap: parsed.recap || '', nextSteps: parsed.nextSteps || [],
576
+ }
577
+ const r = await api($, 'POST', '/v1/sessions/' + encodeURIComponent(st.sessionId) + '/checkpoints', cp, 2000)
578
+ if (r && r.ok && r.json) return afterCheckpoint($, r.json, trigger)
579
+ const written = await writeCheckpointDirect($, cp)
580
+ return afterCheckpoint($, written || cp, trigger)
581
+ } finally {
582
+ st.checkpointing = false
583
+ }
584
+ }
585
+
586
+ function afterCheckpoint($, cp, trigger) {
587
+ st.lastCheckpoint = cp
588
+ if (st.record) {
589
+ st.record.turnsSinceCheckpoint = 0
590
+ st.record.lastCheckpointAt = cp.createdAt || nowIso()
591
+ if (trigger === 'dormant') st.record.dormantStretch = st.record.lastActivityAt
592
+ }
593
+ $.ui.toast('Checkpoint saved · Next: ' + clip(cp.next, 120), { timeoutMs: 6000 })
594
+ $.ui.invalidate('ui.render')
595
+ return cp
596
+ }
597
+
598
+ async function nameSession($, args) {
599
+ const title = String(args || '').trim()
600
+ if (!title) { $.ui.toast('Usage: /' + st.nameCommand + ' <title>'); return {} }
601
+ if (tabTitleOn()) {
602
+ if (!st.record) st.record = {}
603
+ st.record.title = title
604
+ await applyTabTitle($, title, { force: true }) // runs /rename "<title>" (plus " · cp:<id>" when Go needs it)
605
+ } else {
606
+ try { await $.command.run({ command: 'rename', args: title }) } catch {}
607
+ }
608
+ await sendOrSpool($, 'title_changed', { title, titleSource: 'custom' })
609
+ $.ui.toast('Named this session “' + clip(title, 60) + '”')
610
+ return {}
611
+ }
612
+
613
+ async function refreshPane($) {
614
+ if (st.paneTimer) {
615
+ // Stop refreshing once the person has closed the pane.
616
+ let open = true
617
+ try { open = (await $.ui.panes()).some((p) => p.id === PANE_ID) } catch {}
618
+ if (!open) { st.paneTimer.cancel(); st.paneTimer = null; return }
619
+ }
620
+ const r = await api($, 'GET', '/v1/sessions?status=active,waiting,background,needs_input,dormant,stale&limit=30', undefined, 1500)
621
+ if (r && r.ok && r.json) {
622
+ // Empty sessions (no prompt yet, v0.4.8) stay off the pane, except this one.
623
+ st.paneRows = r.json.sessions.filter((s) => !s.empty || s.id === st.sessionId).map((s) => ({
624
+ id: s.id, title: s.title || '(untitled)', status: s.status, repo: s.repoLabel || s.repoName, where: (s.repoLabel || s.repoName) + (s.repo && s.repo.branch ? ' · ' + s.repo.branch : ''),
625
+ idleSec: s.idleSec, next: s.latestCheckpoint ? s.latestCheckpoint.next : '', resume: s.resumeCommand,
626
+ live: s.live || 'unknown', canGo: !!(s.jump && s.jump.supported), goWhy: (s.jump && s.jump.reason) || '',
627
+ needs: s.needsYou && s.needsYou.blocking ? s.needsYou : null,
628
+ }))
629
+ st.paneNeeds = st.paneRows.filter((x) => x.needs).length
630
+ st.paneNote = 'Board: http://127.0.0.1:' + st.port + '/ · b opens it'
631
+ // One extra line when the daemon's daily check found a newer version.
632
+ const h = await api($, 'GET', '/v1/health', undefined, 1000)
633
+ const u = h && h.ok && h.json && h.json.update
634
+ if (u && u.available) st.paneNote += ' · update available' + (u.remoteVersion ? ' (v' + u.remoteVersion + ')' : '') + ': run leftoff upgrade'
635
+ } else {
636
+ const rows = []
637
+ try {
638
+ for (const k of await $.store.keys()) {
639
+ if (!String(k).startsWith('session:')) continue
640
+ const s = await $.store.get(k)
641
+ if (!s || s.endedAt) continue
642
+ rows.push({ id: s.id, title: s.title || '(untitled)', status: 'unknown', repo: s.repo ? (s.repo.name || '') : '', where: s.repo ? (s.repo.name || '') : '', idleSec: null, next: s.next || '', resume: resumeCommand(s) })
643
+ }
644
+ } catch {}
645
+ st.paneRows = rows
646
+ st.paneNeeds = 0
647
+ st.paneNote = 'Daemon offline: showing the local registry. Start it with: leftoff serve'
648
+ }
649
+ st.paneRows.sort((a, b) => (b.id === st.sessionId) - (a.id === st.sessionId))
650
+ st.paneAll = st.paneRows
651
+ applyPaneRepo()
652
+ $.ui.invalidate('ui.render')
653
+ }
654
+
655
+ // The pane's repo filter (v0.4.2): `f` steps through the repos with open
656
+ // sessions (most recent first), then back to all of them.
657
+ function paneRepos() {
658
+ const seen = []
659
+ for (const r of st.paneAll || []) if (r.repo && !seen.includes(r.repo)) seen.push(r.repo)
660
+ return seen
661
+ }
662
+ function applyPaneRepo() {
663
+ const all = st.paneAll || st.paneRows
664
+ if (st.paneRepo && !all.some((r) => r.repo === st.paneRepo)) st.paneRepo = null
665
+ st.paneRows = st.paneRepo ? all.filter((r) => r.repo === st.paneRepo) : all
666
+ if (st.paneSel >= st.paneRows.length) st.paneSel = Math.max(0, st.paneRows.length - 1)
667
+ }
668
+ function cyclePaneRepo() {
669
+ const repos = paneRepos()
670
+ const i = st.paneRepo ? repos.indexOf(st.paneRepo) : -1
671
+ st.paneRepo = i + 1 < repos.length ? repos[i + 1] : null
672
+ st.paneSel = 0
673
+ applyPaneRepo()
674
+ return st.paneRepo
675
+ }
676
+
677
+ // Go: bring a session's terminal tab to the front. The CLI runs the jump
678
+ // (so macOS asks the terminal, not the daemon, for Automation permission);
679
+ // without the CLI on PATH the daemon does it.
680
+ async function goTo($, row) {
681
+ if (!row) return
682
+ if (row.id === st.sessionId) { $.ui.toast('That’s this session'); return }
683
+ if (!row.canGo) { $.ui.toast('Can’t jump to “' + clip(row.title, 40) + '”' + (row.goWhy ? ': ' + row.goWhy : ''), { timeoutMs: 8000 }); return }
684
+ // Feedback at once: a slow Go should never look like nothing happened.
685
+ $.ui.toast('Switching to “' + clip(row.title, 40) + '”…', { timeoutMs: 20000 })
686
+ const t0 = Date.now()
687
+ let msg = null
688
+ try {
689
+ const r = await runCli($, ['go', '--id=' + row.id, '--plain'], { timeoutMs: 30000 })
690
+ if (r && (r.exitCode === 0 || r.exitCode === 3)) msg = String(r.stdout || '').trim().split('\n').pop()
691
+ } catch {}
692
+ if (!msg) {
693
+ const r = await api($, 'POST', '/v1/sessions/' + encodeURIComponent(row.id) + '/go', {}, 30000)
694
+ msg = r && r.json ? (r.json.message || (r.json.error && r.json.error.message) || null) : 'Couldn’t reach the daemon'
695
+ if (msg && r && r.json && r.json.elapsedMs != null) msg += ' in ' + (r.json.elapsedMs / 1000).toFixed(1) + ' s'
696
+ }
697
+ if (!msg) msg = 'Switched in ' + ((Date.now() - t0) / 1000).toFixed(1) + ' s'
698
+ $.ui.toast(msg, { timeoutMs: 8000 })
699
+ }
700
+
701
+ function needsLine(n) {
702
+ if (n.ask && n.ask.describe) return n.ask.describe.verb + ': ' + n.ask.describe.detail
703
+ if (n.ask && n.ask.questions && n.ask.questions[0]) return n.ask.questions[0].question
704
+ return n.message || n.question || 'waiting'
705
+ }
706
+
707
+ function idleLabel(sec) {
708
+ if (sec == null) return ''
709
+ if (sec < 60) return 'now'
710
+ if (sec < 3600) return Math.round(sec / 60) + 'm'
711
+ return Math.round(sec / 3600) + 'h'
712
+ }
713
+
714
+ const DOT = { active: 'green', waiting: 'yellow', background: 'cyan', needs_input: 'red', dormant: 'gray', stale: 'gray', ended: 'gray' }
715
+
716
+ function textList() {
717
+ if (!st.paneRows.length) return 'No open sessions. ' + st.paneNote
718
+ return st.paneRows.map((r) => (r.id === st.sessionId ? '▶ ' : ' ') + r.status.padEnd(11) + ' ' + r.title + ' (' + r.where + ')' + (r.next ? '\n Next: ' + clip(r.next, 100) : '')).join('\n') + '\n' + st.paneNote
719
+ }
720
+
721
+
722
+ // ---------------------------------------------------------------- Needs you (v0.4.4)
723
+
724
+ // Claude Code shows its permission dialog (or AskUserQuestion's picker) and runs
725
+ // this hook beside it; whichever answers first wins (checked on 2.1.295). We put
726
+ // the request on the board and long-poll the daemon. Time spent inside
727
+ // $.http.fetch doesn't count against the hook's 10 s budget. A board answer is
728
+ // returned as the decision; anything else returns what came back from next(e),
729
+ // so the terminal decides. Nothing is ever allowed without a person's click.
730
+ const ASK_POLL_MS = 20000
731
+ const ASK_MAX_MS = 9.5 * 60 * 1000
732
+
733
+ async function permissionHook($, e, next) {
734
+ const base = await next(e)
735
+ if (st.internal || !st.sessionId || (base && base.decision)) return base
736
+ const opened = await api($, 'POST', '/v1/asks', {
737
+ sessionId: e.session_id || st.sessionId, tool: e.tool_name, input: e.tool_input, suggestions: e.permission_suggestions,
738
+ capture: 'mod', cwd: e.cwd, permissionMode: e.permission_mode,
739
+ }, 1500)
740
+ const id = opened && opened.ok && opened.json && opened.json.state === 'waiting' ? opened.json.id : null
741
+ if (!id) return base
742
+ const mine = { tool: e.tool_name, input: e.tool_input, done: false }
743
+ st.asks[id] = mine
744
+ let aborted = false
745
+ // Esc in the terminal abandons the event (next.signal aborts): tell the board.
746
+ try {
747
+ next.signal.addEventListener('abort', () => {
748
+ aborted = true
749
+ if (!mine.done) { mine.done = true; api($, 'POST', '/v1/asks/' + id + '/cancel', { reason: 'cancelled_in_terminal' }, 800).catch(() => {}) }
750
+ })
751
+ } catch {}
752
+ const t0 = Date.now()
753
+ try {
754
+ while (!mine.done && !aborted && Date.now() - t0 < ASK_MAX_MS) {
755
+ const r = await api($, 'GET', '/v1/asks/' + id + '/wait?ms=' + ASK_POLL_MS, undefined, ASK_POLL_MS + 5000)
756
+ if (!r || !r.ok || !r.json) break // daemon gone: the terminal decides
757
+ if (r.json.state === 'answered' && r.json.decision && !aborted && !mine.done) {
758
+ mine.done = true
759
+ return { ...(base || {}), decision: r.json.decision }
760
+ }
761
+ if (r.json.state !== 'waiting') break
762
+ }
763
+ } finally {
764
+ mine.done = true
765
+ delete st.asks[id]
766
+ }
767
+ return base
768
+ }
769
+
770
+ // The tool ran: if the board was still showing its request, the terminal
771
+ // answered it first (Claude Code doesn't abandon our hook in that case).
772
+ async function toolRan($, e) {
773
+ const ids = Object.keys(st.asks).filter((id) => !st.asks[id].done && st.asks[id].tool === e.tool_name)
774
+ if (!ids.length) return
775
+ for (const id of ids) st.asks[id].done = true
776
+ await api($, 'POST', '/v1/asks/settle', { sessionId: e.session_id || st.sessionId, tool: e.tool_name, input: e.tool_input, reason: 'answered_in_terminal' }, 800)
777
+ }
778
+
779
+ // Replies typed on the board: a long-poll the daemon answers with the next one,
780
+ // typed in with $.prompt.submit, which Claude Code runs once the session is idle
781
+ // (checked on 2.1.295: a submit during a turn waits for it to end).
782
+ function startReplyLoop($) {
783
+ if (st.replyLoop || st.internal || st.surface === 'headless') return
784
+ st.replyLoop = true
785
+ const loop = async () => {
786
+ let backoff = 0
787
+ while (st.replyLoop && st.sessionId) {
788
+ const sid = st.sessionId
789
+ const r = await api($, 'GET', '/v1/sessions/' + encodeURIComponent(sid) + '/replies/next?ms=' + ASK_POLL_MS, undefined, ASK_POLL_MS + 5000)
790
+ if (!r || !r.ok) {
791
+ // Daemon down: try again on the heartbeat rather than spinning.
792
+ st.replyLoop = false
793
+ return
794
+ }
795
+ const reply = r.json && r.json.reply
796
+ if (reply && reply.text && !String(reply.text).startsWith('/') && sid === st.sessionId) {
797
+ try {
798
+ await $.prompt.submit({ text: String(reply.text) })
799
+ } catch (err) {
800
+ try { $.ui.toast('Leftoff: couldn’t send the board’s reply: ' + String((err && err.message) || err).slice(0, 120)) } catch {}
801
+ }
802
+ }
803
+ backoff = 0
804
+ }
805
+ st.replyLoop = false
806
+ }
807
+ loop().catch(() => { st.replyLoop = false })
808
+ }
809
+
810
+ export function register(on, options) {
811
+ on('session.start', async ($, e, next) => {
812
+ try {
813
+ if (await isInternal($)) { st.internal = true; return next(e) }
814
+ await loadSetup($)
815
+ st.sessionId = await $.session.id()
816
+ const cwd = await $.session.cwd()
817
+ const repo = await readRepo($, cwd)
818
+ let model = null
819
+ try { model = await $.session.model() } catch {}
820
+ let surface = null
821
+ try { const surfaces = await $.session.surfaces(); surface = surfaces && surfaces.length ? surfaces[0] : 'headless' } catch {}
822
+ st.record = null
823
+ // Where this session runs: Claude Code's pid + tty (for open/closed and
824
+ // Terminal.app) and the terminal's own ids (for Go).
825
+ st.proc = await probeProcess($)
826
+ const termEnv = await readTermEnv($)
827
+ st.term = detectTerminal(termEnv)
828
+ st.surface = surface
829
+ st.hello = { cwd, repo, model, surface, proc: st.proc ? { ...st.proc, source: 'mod' } : null, term: st.term }
830
+ await sendOrSpool($, 'session.opened', st.hello)
831
+ if (st.heartbeat) st.heartbeat.cancel()
832
+ st.heartbeat = $.clock.every(60000, () => { heartbeat($).catch(() => {}); startReplyLoop($) })
833
+ st.ready = true
834
+ startReplyLoop($)
835
+ } catch {}
836
+ // Commands last, each on its own: a taken name throws (spec §15 Q3), so a
837
+ // clash costs only that command, and registerCommands reports it.
838
+ await registerCommands($)
839
+ return next(e)
840
+ })
841
+
842
+ on('classic.SessionStart', async ($, e, next) => {
843
+ if (!st.internal && e.session_id) {
844
+ let parentSessionId
845
+ if (st.sessionId && e.session_id !== st.sessionId) {
846
+ // /clear, /resume or a fork: a new id in the same process (spec §15 Q4).
847
+ parentSessionId = st.sessionId
848
+ st.sessionId = e.session_id
849
+ st.record = { ...(st.record || {}), id: e.session_id, turns: 0, turnsSinceCheckpoint: 0, endedAt: null, title: null, titleSource: null, startedAt: null }
850
+ st.lastCheckpoint = null
851
+ st.titleSoon = false
852
+ st.daemonTitle = null
853
+ }
854
+ if (!st.sessionId) st.sessionId = e.session_id
855
+ await sendOrSpool($, 'session.started', {
856
+ source: e.source, transcriptPath: e.transcript_path, cwd: e.cwd, sessionTitle: e.session_title, model: e.model, parentSessionId,
857
+ // a new id in the same process (/clear, /resume) runs in the same tab
858
+ ...(parentSessionId ? { proc: st.proc ? { ...st.proc, source: 'mod' } : null, term: st.term } : {}),
859
+ })
860
+ if (st.surface !== 'headless') {
861
+ const own = e.session_title ? parseTabTitle(e.session_title) : null
862
+ const name = own && !own.id ? own.name : (own && own.name) || st.daemonTitle || null
863
+ await applyTabTitle($, name, { force: !!parentSessionId, current: e.session_title || null })
864
+ }
865
+ }
866
+ return next(e)
867
+ })
868
+
869
+ on('classic.UserPromptSubmit', async ($, e, next) => {
870
+ await sendOrSpool($, 'activity', { kind: 'prompt', prompt: clip(e.prompt || '', 300), sessionTitle: e.session_title && e.session_title === st.tabTitle ? null : e.session_title }) // our own /rename isn't a name you chose
871
+ return next(e)
872
+ })
873
+
874
+ // v0.5.1: when this session's saved lists changed since Claude last heard
875
+ // (saved, ticked on the board, a resumed session), the prompt carries a
876
+ // context entry: the list, its numbers, and `leftoff todo done <list> <n>`.
877
+ // Context goes on the event before next(); on the result it would be too late.
878
+ on('prompt.submit', async ($, e, next) => {
879
+ const ctx = await listContextFor($)
880
+ return next(ctx ? { ...e, context: [...(Array.isArray(e.context) ? e.context : []), ctx] } : e)
881
+ })
882
+
883
+ on('turn.start', async ($, e, next) => {
884
+ await sendOrSpool($, 'turn_started', {})
885
+ return next(e)
886
+ })
887
+
888
+ on('turn.complete', async ($, e, next) => {
889
+ // classic.Stop reports a normal end with its details; Stop doesn't fire for
890
+ // an interrupted or failed turn, so those are reported here.
891
+ if (e.reason === 'aborted' || e.reason === 'error' || e.reason === 'refusal') await sendOrSpool($, 'turn_completed', { endReason: e.reason })
892
+ return next(e)
893
+ })
894
+
895
+ on('classic.Stop', async ($, e, next) => {
896
+ // v0.5.1: a checklist of 3+ in this reply (a cheap regex, no model) -> the
897
+ // board's card offers "Claude wrote a 7-step list · Save". Nothing is saved here.
898
+ const listHint = listHintFrom(e.last_assistant_message || '', { fallback: st.daemonTitle || null })
899
+ await sendOrSpool($, 'turn_completed', {
900
+ lastAssistantPreview: clip(e.last_assistant_message || '', 280),
901
+ question: trailingQuestion(e.last_assistant_message || ''),
902
+ ...(listHint ? { listHint } : {}),
903
+ backgroundTasks: Array.isArray(e.background_tasks) ? e.background_tasks.length : 0,
904
+ sessionCrons: Array.isArray(e.session_crons) ? e.session_crons.length : 0,
905
+ })
906
+ // The daemon titles a session after its first turn (a few seconds of
907
+ // summarizer); name the tab once it has, without waiting for the minute heartbeat.
908
+ if (st.daemonTitle && st.surface !== 'headless') await noteTitle($, st.daemonTitle)
909
+ else if (!st.titleSoon && st.surface !== 'headless' && tabTitleOn()) {
910
+ st.titleSoon = true
911
+ $.clock.after(12000, () => { heartbeat($).catch(() => {}) })
912
+ }
913
+ return next(e)
914
+ })
915
+
916
+ on('classic.Notification', async ($, e, next) => {
917
+ await sendOrSpool($, 'notification', { notificationType: e.notification_type || null, message: clip(e.message || '', 200) })
918
+ return next(e)
919
+ })
920
+
921
+ on('classic.PermissionRequest', permissionHook)
922
+
923
+ on('classic.PostToolUse', async ($, e, next) => { await toolRan($, e); return next(e) })
924
+
925
+ on('classic.PostToolUseFailure', async ($, e, next) => { await toolRan($, e); return next(e) })
926
+
927
+ on('session.end', async ($, e, next) => {
928
+ // About 1.5 s shared by every SessionEnd hook: post and go. The daemon makes
929
+ // the final checkpoint later from transcript_path.
930
+ await sendOrSpool($, 'session.ended', { reason: e.reason }, END_TIMEOUT_MS)
931
+ if (e.reason !== 'clear' && e.reason !== 'resume' && st.heartbeat) { st.heartbeat.cancel(); st.heartbeat = null; st.replyLoop = false }
932
+ return next(e)
933
+ })
934
+
935
+ // No hooks on 'checkpoint' or 'name': those are aliases of the built-in
936
+ // /rewind and /rename, and a hook here would sit in front of them.
937
+ on('command.run', { command: 'cp' }, async ($, e) => checkpointCommand($, e.args))
938
+
939
+ on('command.run', { command: 'cp-save' }, async ($, e) => checkpointCommand($, e.args))
940
+
941
+ on('command.run', { command: 'cp-name' }, async ($, e) => nameSession($, e.args))
942
+
943
+ on('command.run', { command: 'leftoff' }, async ($, e) => paneCommand($, e.args))
944
+ on('command.run', { command: 'cp-catchup' }, async ($, e) => makeCatchup($, e.args))
945
+ on('command.run', { command: 'cp-todo' }, async ($, e) => saveTodo($, e.args))
946
+ on('command.run', { command: 'leftoff-todo' }, async ($, e) => saveTodo($, e.args))
947
+ on('command.run', { command: 'cp-done' }, async ($, e) => tickTodo($, e.args))
948
+ on('command.run', { command: 'leftoff-done' }, async ($, e) => tickTodo($, e.args))
949
+ on('command.run', { command: 'leftoff-catchup' }, async ($, e) => makeCatchup($, e.args))
950
+
951
+ on('command.run', { command: 'checkpoints' }, async ($, e) => paneCommand($, e.args))
952
+
953
+ on('ui.render', { component: 'Pane' }, async ($, e, next) => {
954
+ if (e.requestId !== PANE_ID) return next(e)
955
+ const { Box, Text, Button } = $.ui.resolve(e)
956
+ const width = (e.props && e.props.bodyColumns) || 60
957
+ const rows = st.paneRows.map((r, i) => Box({
958
+ key: 'row-' + r.id,
959
+ flexDirection: 'column',
960
+ children: [
961
+ Box({ flexDirection: 'row', columnGap: 1, children: [
962
+ Text({ color: i === st.paneSel ? 'cyan' : 'gray', children: [i === st.paneSel ? '›' : ' '] }),
963
+ Text({ dimColor: true, children: [i < 9 ? String(i + 1) : ' '] }),
964
+ Text({ color: r.needs ? 'red' : r.live === 'closed' ? 'gray' : (DOT[r.status] || 'white'), children: [r.needs ? '!' : r.live === 'closed' ? '○' : '●'] }),
965
+ Text({ bold: r.id === st.sessionId, wrap: 'truncate-end', children: [clip(r.title, Math.max(10, width - 18))] }),
966
+ Text({ dimColor: true, children: [idleLabel(r.idleSec)] }),
967
+ ] }),
968
+ Text({ dimColor: !r.needs, color: r.needs ? 'red' : undefined, wrap: 'truncate-end', children: [' ' + (r.needs ? 'Needs you: ' + needsLine(r.needs) : r.where + (r.next ? ' · Next: ' + r.next : ''))] }),
969
+ ],
970
+ }))
971
+ return Box({
972
+ flexDirection: 'column',
973
+ children: [
974
+ ...(st.paneNeeds ? [Text({ color: 'red', bold: true, children: [st.paneNeeds + (st.paneNeeds === 1 ? ' session needs you' : ' sessions need you') + ' · answer on the board (b) or Go'] })] : []),
975
+ ...(rows.length ? rows : [Text({ dimColor: true, children: ['No open sessions yet.'] })]),
976
+ Text({ children: [' '] }),
977
+ Box({ flexDirection: 'row', columnGap: 2, children: [
978
+ Button({ key: 'up', label: '↑', hotkey: 'k', onPress: async () => { st.paneSel = Math.max(0, st.paneSel - 1); $.ui.invalidate('ui.render') } }),
979
+ Button({ key: 'down', label: '↓', hotkey: 'j', onPress: async () => { st.paneSel = Math.min(Math.max(0, st.paneRows.length - 1), st.paneSel + 1); $.ui.invalidate('ui.render') } }),
980
+ Button({ key: 'go', label: 'Go', hotkey: 'g', onPress: async () => { await goTo($, st.paneRows[st.paneSel]) } }),
981
+ ...st.paneRows.slice(0, 9).map((r, i) => Button({ key: 'go' + (i + 1), label: String(i + 1), hotkey: String(i + 1), onPress: async () => { st.paneSel = i; await goTo($, r) } })),
982
+ ] }),
983
+ Box({ flexDirection: 'row', columnGap: 2, children: [
984
+ Button({ key: 'cp', label: 'Checkpoint', hotkey: 'c', onPress: async () => { await makeCheckpoint($, 'manual', null) } }),
985
+ Button({ key: 'copy', label: 'Copy resume', hotkey: 'r', onPress: async () => {
986
+ const me = st.paneRows.find((r) => r.id === st.sessionId)
987
+ const cmd = me ? me.resume : resumeCommand({ id: st.sessionId, cwd: st.record && st.record.cwd })
988
+ try { await $.ui.copy(cmd); $.ui.toast('Copied: ' + cmd) } catch { $.ui.toast(cmd, { timeoutMs: 10000 }) }
989
+ } }),
990
+ Button({ key: 'ui', label: 'Open board', hotkey: 'b', onPress: async () => { await openBoard($) } }),
991
+ Button({ key: 'catchup', label: 'Catch me up', hotkey: 'u', onPress: async () => {
992
+ $.ui.toast('Catching you up…')
993
+ const r = await makeCatchup($, '')
994
+ $.ui.toast(String((r && r.text) || '').split('\n').filter(Boolean).slice(0, 3).join(' · '), { timeoutMs: 12000 })
995
+ } }),
996
+ Button({ key: 'repo', label: st.paneRepo ? 'Repo: ' + st.paneRepo : 'Repo: all', hotkey: 'f', onPress: async () => {
997
+ const now = cyclePaneRepo()
998
+ $.ui.toast(now ? 'Showing ' + now + ' (' + st.paneRows.length + ') · f for the next repo' : 'Showing all repos')
999
+ $.ui.invalidate('ui.render')
1000
+ } }),
1001
+ ] }),
1002
+ Text({ dimColor: true, children: [st.paneNote] }),
1003
+ ],
1004
+ })
1005
+ })
1006
+ }