@leftoff/cli 0.0.0-stage → 0.6.3

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 +564 -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 +1282 -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,1397 @@
1
+ // Leftoff shared core (the mod, the daemon and the CLI all import it).
2
+ //
3
+ // Pure functions only: no Node.js APIs, no timers, no I/O. The Claude Code mod
4
+ // (hooks/register.js) and the local daemon (src/) both import this file, so the
5
+ // folder format, the status rules and the checkpoint prompt are defined once.
6
+ // A mod's hooks module may import files inside its own plugin directory by
7
+ // relative path, which is why this lives here and the daemon reaches in.
8
+
9
+ export const SCHEMA = 1
10
+ export const DEFAULT_PORT = 7429
11
+
12
+ export const DEFAULT_CONFIG = {
13
+ port: DEFAULT_PORT,
14
+ dormantAfterMin: 15,
15
+ minGapMin: 30,
16
+ staleAfterSec: 180,
17
+ longRunCheckpointMin: null,
18
+ digest: { time: '07:00', lookbackHours: 24, topOfMind: true, days: ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'], skipEmpty: true, timeZone: null },
19
+ summarizer: { provider: 'auto', model: 'haiku', baseUrl: null, apiKeyEnv: null, maxInputChars: 24000 },
20
+ machine: null,
21
+ machineNames: {},
22
+ redact: { patterns: ['(?i)api[_-]?key\\s*[:=]\\s*\\S+', 'sk-[A-Za-z0-9_-]{20,}'] },
23
+ ignoreRepos: [],
24
+ updates: { check: true, source: null }, // daily `git ls-remote` of the repo; never installs anything
25
+ remoteHosts: [], // extra Host names the daemon answers (e.g. a Tailscale Serve name); see `leftoff tailscale`
26
+ remoteRequireToken: true, // remote browsers sign in with a one-time link instead of getting the cookie on page load
27
+ tabTitle: true, // the mod names the terminal tab after the session (through Claude Code's own /rename; see tabTitleFormat)
28
+ tabTitleFormat: '{name}', // {name} = the session's title (or its repo), {repo}, {id} = 6-char id. Since 0.5.5 the "· cp:<id>" marker is added only when Go needs it (see formatTabTitle)
29
+ liveCheckSec: 30, // how often the daemon checks that each session's Claude Code process is still running
30
+ notify: { needsYou: true, delaySec: 10 }, // macOS notification when a session waits on a permission prompt or question (v0.4.4)
31
+ remote: { allowAnswers: false }, // answering prompts from another device over Tailscale (off: only from this machine)
32
+ summary: { length: 'informative' }, // checkpoint length: short | informative | long (v0.4.6; /cp --short / --long override once)
33
+ }
34
+
35
+ export const STATUSES = ['active', 'waiting', 'background', 'needs_input', 'dormant', 'ended', 'stale']
36
+ export const TRIGGERS = ['dormant', 'manual', 'session_end', 'clear', 'long_run']
37
+ export const EVENT_TYPES = [
38
+ 'session.opened', 'session.started', 'activity', 'turn_started', 'turn_completed',
39
+ 'notification', 'title_changed', 'heartbeat', 'session.ended',
40
+ ]
41
+ export const NEEDS_INPUT_NOTIFICATIONS = ['permission_prompt', 'elicitation_dialog', 'agent_needs_input']
42
+
43
+ // ---------------------------------------------------------------- small helpers
44
+
45
+ export function mergeConfig(base, over) {
46
+ const out = Array.isArray(base) ? base.slice() : { ...base }
47
+ if (!over || typeof over !== 'object') return out
48
+ for (const k of Object.keys(over)) {
49
+ const b = base ? base[k] : undefined
50
+ const o = over[k]
51
+ if (b && o && typeof b === 'object' && typeof o === 'object' && !Array.isArray(b) && !Array.isArray(o)) out[k] = mergeConfig(b, o)
52
+ else out[k] = o
53
+ }
54
+ return out
55
+ }
56
+
57
+ export function id8(id) {
58
+ return String(id || '').replace(/[^A-Za-z0-9]/g, '').slice(0, 8).toLowerCase() || 'unknown0'
59
+ }
60
+
61
+ export function slugify(text, max = 40) {
62
+ const s = String(text || '')
63
+ .toLowerCase()
64
+ .normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
65
+ .replace(/[^a-z0-9]+/g, '-')
66
+ .replace(/^-+|-+$/g, '')
67
+ return (s.slice(0, max).replace(/-+$/g, '')) || 'untitled'
68
+ }
69
+
70
+ export function basename(p) {
71
+ const parts = String(p || '').split(/[\\/]+/).filter(Boolean)
72
+ return parts.length ? parts[parts.length - 1] : ''
73
+ }
74
+
75
+ export function repoName(session) {
76
+ const r = session && session.repo
77
+ if (r && r.name) return String(r.name).split('/').pop()
78
+ if (r && r.root) return basename(r.root)
79
+ return basename(session && session.cwd) || 'no-repo'
80
+ }
81
+
82
+ export function clip(text, n) {
83
+ const s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim()
84
+ return s.length > n ? s.slice(0, Math.max(0, n - 1)).trimEnd() + '…' : s
85
+ }
86
+
87
+ export function toIso(t) {
88
+ const d = t instanceof Date ? t : new Date(typeof t === 'number' ? t : Date.parse(t))
89
+ if (isNaN(d.getTime())) return null
90
+ return d.toISOString().replace(/\.\d{3}Z$/, 'Z')
91
+ }
92
+
93
+ export function ms(t) {
94
+ if (t == null || t === '') return NaN
95
+ if (typeof t === 'number') return t
96
+ return Date.parse(t)
97
+ }
98
+
99
+ // 2026-10-09T14:56:02Z -> 20261009T145602Z
100
+ export function compactStamp(iso) {
101
+ return toIso(iso).replace(/[-:]/g, '')
102
+ }
103
+
104
+ // 2026-10-09T14:56:02Z -> 2026-10-09T145602Z (the checkpoint file name, no .md)
105
+ export function fileStamp(iso) {
106
+ const s = toIso(iso)
107
+ return s.slice(0, 10) + 'T' + s.slice(11, 19).replace(/:/g, '') + 'Z'
108
+ }
109
+
110
+ export function checkpointId(sessionId, createdAt, n = 0) {
111
+ return 'cp_' + compactStamp(createdAt) + '_' + id8(sessionId) + (n ? '-' + (n + 1) : '')
112
+ }
113
+
114
+ export function checkpointFileName(createdAt, n = 0) {
115
+ return fileStamp(createdAt) + (n ? '-' + (n + 1) : '') + '.md'
116
+ }
117
+
118
+ // {start-date}_{repo}_{slug of first title}_{first 8 chars of id}
119
+ export function sessionFolderName(session) {
120
+ const date = (toIso(session.startedAt || Date.now()) || '').slice(0, 10)
121
+ return [date, slugify(repoName(session), 24), slugify(session.title, 40), id8(session.id)].join('_')
122
+ }
123
+
124
+ // Multi-machine: every record carries the machine it was made on, and each
125
+ // machine writes only under its own subfolder (sessions/<machine>/,
126
+ // digests/<machine>/, .spool/<machine>/), so two machines syncing one folder
127
+ // never write the same file.
128
+ export function machineSlug(name) {
129
+ return slugify(String(name || '').replace(/\.local$/i, ''), 40) || 'unknown'
130
+ }
131
+
132
+ // env override > config machineNames[hostname] > config machine > hostname
133
+ export function resolveMachine({ env, hostname, cfg = {} } = {}) {
134
+ if (env) return machineSlug(env)
135
+ const names = cfg.machineNames || {}
136
+ if (hostname && names[hostname]) return machineSlug(names[hostname])
137
+ if (cfg.machine) return machineSlug(cfg.machine)
138
+ return machineSlug(hostname || 'unknown')
139
+ }
140
+
141
+ export function camelToSnake(k) { return k.replace(/[A-Z]/g, (m) => '_' + m.toLowerCase()) }
142
+ export function snakeToCamel(k) { return k.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase()) }
143
+
144
+ function mapKeys(obj, fn, deep) {
145
+ if (Array.isArray(obj)) return obj.map((v) => (deep ? mapKeys(v, fn, deep) : v))
146
+ if (!obj || typeof obj !== 'object') return obj
147
+ const out = {}
148
+ for (const [k, v] of Object.entries(obj)) out[fn(k)] = deep && v && typeof v === 'object' ? mapKeys(v, fn, deep) : v
149
+ return out
150
+ }
151
+ export const toSnake = (o) => mapKeys(o, camelToSnake, true)
152
+ export const toCamel = (o) => mapKeys(o, snakeToCamel, true)
153
+
154
+ // ---------------------------------------------------------------- frontmatter
155
+ // A small YAML subset: `key: value` lines; values are null, booleans, numbers,
156
+ // plain or double/single-quoted strings, flow sequences [a, b], flow maps
157
+ // {k: v}, and block sequences ("- item" lines) for hand edits. Enough for what
158
+ // we write and for what a person typing in a markdown app is likely to change.
159
+
160
+ const RESERVED = /^(null|~|true|false|yes|no|on|off|y|n)$/i
161
+ const NUMBERISH = /^[-+]?(\d[\d_]*)?(\.\d+)?([eE][-+]?\d+)?$/
162
+
163
+ function needsQuotes(s) {
164
+ if (s === '') return true
165
+ if (RESERVED.test(s)) return true
166
+ if (NUMBERISH.test(s) && /\d/.test(s)) return true
167
+ if (/^[\s\-?:,\[\]{}#&*!|>'"%@`]/.test(s)) return true
168
+ if (/\s$/.test(s)) return true
169
+ if (/:(\s|$)|\s#|[,\[\]{}\n\r\t"]/.test(s)) return true
170
+ return false
171
+ }
172
+
173
+ export function fmScalar(v) {
174
+ if (v === null || v === undefined) return 'null'
175
+ if (typeof v === 'boolean' || typeof v === 'number') return Number.isFinite(v) || typeof v === 'boolean' ? String(v) : 'null'
176
+ if (Array.isArray(v)) return '[' + v.map(fmScalar).join(', ') + ']'
177
+ if (typeof v === 'object') {
178
+ const parts = Object.entries(v).filter(([, x]) => x !== undefined).map(([k, x]) => k + ': ' + fmScalar(x))
179
+ return '{' + parts.join(', ') + '}'
180
+ }
181
+ const s = String(v)
182
+ return needsQuotes(s) ? JSON.stringify(s) : s
183
+ }
184
+
185
+ export function toFrontmatter(obj, order = []) {
186
+ const keys = [...order.filter((k) => k in obj), ...Object.keys(obj).filter((k) => !order.includes(k))]
187
+ const lines = ['---']
188
+ for (const k of keys) {
189
+ if (obj[k] === undefined) continue
190
+ lines.push(k + ': ' + fmScalar(obj[k]))
191
+ }
192
+ lines.push('---')
193
+ return lines.join('\n') + '\n'
194
+ }
195
+
196
+ function splitFlow(inner) {
197
+ const out = []
198
+ let depth = 0, quote = null, cur = ''
199
+ for (let i = 0; i < inner.length; i++) {
200
+ const c = inner[i]
201
+ if (quote) {
202
+ cur += c
203
+ if (c === '\\' && quote === '"') { cur += inner[++i] || ''; continue }
204
+ if (c === quote) {
205
+ if (quote === "'" && inner[i + 1] === "'") { cur += inner[++i]; continue }
206
+ quote = null
207
+ }
208
+ continue
209
+ }
210
+ if (c === '"' || c === "'") { quote = c; cur += c; continue }
211
+ if (c === '[' || c === '{') depth++
212
+ if (c === ']' || c === '}') depth--
213
+ if (c === ',' && depth === 0) { out.push(cur.trim()); cur = ''; continue }
214
+ cur += c
215
+ }
216
+ if (cur.trim() !== '') out.push(cur.trim())
217
+ return out
218
+ }
219
+
220
+ function stripComment(s) {
221
+ // a ' #' outside quotes starts a comment
222
+ let quote = null
223
+ for (let i = 0; i < s.length; i++) {
224
+ const c = s[i]
225
+ if (quote) { if (c === quote) quote = null; continue }
226
+ if (c === '"' || c === "'") { quote = c; continue }
227
+ if (c === '#' && (i === 0 || /\s/.test(s[i - 1]))) return s.slice(0, i).trimEnd()
228
+ }
229
+ return s
230
+ }
231
+
232
+ export function parseScalar(raw) {
233
+ const s = stripComment(String(raw).trim())
234
+ if (s === '' || s === '~' || s === 'null' || s === 'Null' || s === 'NULL') return null
235
+ if (/^(true|True|TRUE)$/.test(s)) return true
236
+ if (/^(false|False|FALSE)$/.test(s)) return false
237
+ if (s[0] === '"') { try { return JSON.parse(s) } catch { return s.slice(1, -1) } }
238
+ if (s[0] === "'") return s.slice(1, s.endsWith("'") ? -1 : undefined).replace(/''/g, "'")
239
+ if (s[0] === '[') return splitFlow(s.slice(1, s.lastIndexOf(']'))).map(parseScalar)
240
+ if (s[0] === '{') {
241
+ const o = {}
242
+ for (const part of splitFlow(s.slice(1, s.lastIndexOf('}')))) {
243
+ const m = part.match(/^("(?:[^"\\]|\\.)*"|'[^']*'|[^:]+?)\s*:\s*(.*)$/)
244
+ if (!m) continue
245
+ o[String(parseScalar(m[1]))] = parseScalar(m[2])
246
+ }
247
+ return o
248
+ }
249
+ if (NUMBERISH.test(s) && /\d/.test(s)) { const n = Number(s.replace(/_/g, '')); if (!isNaN(n)) return n }
250
+ return s
251
+ }
252
+
253
+ export function parseFrontmatter(text) {
254
+ const src = String(text || '').replace(/^\uFEFF/, '').replace(/\r\n/g, '\n')
255
+ if (!src.startsWith('---\n') && src !== '---') return { data: {}, body: src }
256
+ const end = src.indexOf('\n---', 4)
257
+ if (end < 0) return { data: {}, body: src }
258
+ const head = src.slice(4, end)
259
+ let body = src.slice(end + 4)
260
+ body = body.replace(/^[^\n]*\n?/, '') // rest of the closing '---' line
261
+ const data = {}
262
+ const lines = head.split('\n')
263
+ for (let i = 0; i < lines.length; i++) {
264
+ const line = lines[i]
265
+ if (!line.trim() || /^\s*#/.test(line)) continue
266
+ const m = line.match(/^([A-Za-z0-9_.-]+)\s*:\s?(.*)$/)
267
+ if (!m) continue
268
+ const [, key, rest] = m
269
+ if (rest.trim() === '') {
270
+ const items = []
271
+ while (i + 1 < lines.length && /^\s+-\s+/.test(lines[i + 1])) { items.push(parseScalar(lines[++i].replace(/^\s+-\s+/, ''))); }
272
+ data[key] = items.length ? items : null
273
+ } else {
274
+ data[key] = parseScalar(rest)
275
+ }
276
+ }
277
+ return { data, body }
278
+ }
279
+
280
+ // ---------------------------------------------------------------- checkpoint markdown
281
+
282
+ const CP_ORDER = ['id', 'session_id', 'session_title', 'created_at', 'trigger', 'machine', 'repo', 'branch', 'cwd',
283
+ 'files_touched', 'turns_since_last', 'user_note', 'open_question', 'parse_ok', 'length', 'generator', 'schema']
284
+
285
+ export function resumeCommand(session) {
286
+ const id = session.id || session.sessionId
287
+ const cwd = session.cwd
288
+ const q = (p) => (/^[A-Za-z0-9_./~@+-]+$/.test(p) ? p : "'" + String(p).replace(/'/g, "'\\''") + "'")
289
+ return (cwd ? 'cd ' + q(cwd) + ' && ' : '') + 'claude --resume ' + id
290
+ }
291
+
292
+ export function renderCheckpointMd(cp) {
293
+ const fm = {
294
+ id: cp.id,
295
+ session_id: cp.sessionId,
296
+ session_title: cp.sessionTitle || null,
297
+ created_at: toIso(cp.createdAt),
298
+ trigger: cp.trigger,
299
+ machine: cp.machine || undefined,
300
+ repo: cp.repo || null,
301
+ branch: cp.branch || null,
302
+ cwd: cp.cwd || null,
303
+ files_touched: cp.filesTouched || [],
304
+ turns_since_last: cp.turnsSinceLast == null ? null : cp.turnsSinceLast,
305
+ user_note: cp.userNote || undefined,
306
+ open_question: cp.openQuestion || undefined,
307
+ parse_ok: cp.parseOk === false ? false : undefined,
308
+ length: cp.length && SUMMARY_LENGTHS[cp.length] ? cp.length : undefined,
309
+ generator: cp.generator ? toSnake(cp.generator) : { by: 'unknown' },
310
+ schema: SCHEMA,
311
+ }
312
+ const lines = [
313
+ '**Doing:** ' + oneLine(cp.doing),
314
+ '**Stopped at:** ' + oneLine(cp.stoppedAt),
315
+ '**Next:** ' + oneLine(cp.next),
316
+ ]
317
+ if (cp.openQuestion) lines.push('**Open question:** ' + oneLine(cp.openQuestion))
318
+ if (cp.userNote) lines.push('**Note:** ' + oneLine(cp.userNote))
319
+ // Informative / long checkpoints (v0.4.6): sections below the three lines.
320
+ // Older readers only pick up the **Label:** lines, so they still work.
321
+ const bullet = (x) => '- ' + oneLine(x).replace(/^\*+/, '')
322
+ for (const k of SECTION_ORDER) {
323
+ const v = cp[k]
324
+ if (k === 'recap') { if (v) lines.push('', '### ' + SECTION_TITLES[k], oneLine(v).replace(/^\*+/, '')); continue }
325
+ if (!Array.isArray(v) || !v.length) continue
326
+ lines.push('', '### ' + SECTION_TITLES[k])
327
+ if (k === 'files') for (const f of v) lines.push(bullet(f.path ? '`' + String(f.path).replace(/`/g, '') + '`' + (f.why ? ' — ' + oneLine(f.why) : '') : f.why))
328
+ else if (k === 'nextSteps') v.forEach((x, n) => lines.push((n + 1) + '. ' + oneLine(x).replace(/^\*+/, '')))
329
+ else for (const x of v) lines.push(bullet(x))
330
+ }
331
+ const resume = resumeCommand({ id: cp.sessionId, cwd: cp.cwd })
332
+ return toFrontmatter(fm, CP_ORDER) + lines.join('\n') + '\n\nResume: `' + resume + '`\n'
333
+ }
334
+
335
+ function oneLine(s) { return String(s == null ? '' : s).replace(/\s*\n\s*/g, ' ').trim() }
336
+
337
+ const BODY_FIELDS = { 'doing': 'doing', 'stopped at': 'stoppedAt', 'next': 'next', 'open question': 'openQuestion', 'note': 'userNote' }
338
+
339
+ export function parseCheckpointMd(text) {
340
+ const { data, body } = parseFrontmatter(text)
341
+ const cp = {
342
+ id: data.id || null,
343
+ sessionId: data.session_id || null,
344
+ sessionTitle: data.session_title || null,
345
+ createdAt: data.created_at ? toIso(data.created_at) : null,
346
+ trigger: data.trigger || 'manual',
347
+ machine: data.machine == null ? null : String(data.machine),
348
+ repo: data.repo == null ? null : String(data.repo),
349
+ branch: data.branch == null ? null : String(data.branch),
350
+ cwd: data.cwd || null,
351
+ filesTouched: Array.isArray(data.files_touched) ? data.files_touched.map(String) : [],
352
+ turnsSinceLast: typeof data.turns_since_last === 'number' ? data.turns_since_last : null,
353
+ userNote: data.user_note || null,
354
+ openQuestion: data.open_question || null,
355
+ parseOk: data.parse_ok === false ? false : true,
356
+ generator: data.generator && typeof data.generator === 'object' ? toCamel(data.generator) : { by: 'unknown' },
357
+ length: data.length && SUMMARY_LENGTHS[data.length] ? data.length : null,
358
+ doing: '', stoppedAt: '', next: '',
359
+ }
360
+ const lines = body.split('\n')
361
+ let k = 0
362
+ for (; k < lines.length; k++) {
363
+ const line = lines[k]
364
+ if (/^###\s/.test(line)) break // the sections of an informative / long checkpoint
365
+ const m = line.match(/^\s*\*\*([A-Za-z ]+):\*\*\s*(.*)$/)
366
+ if (!m) continue
367
+ const f = BODY_FIELDS[m[1].trim().toLowerCase()]
368
+ if (f) cp[f] = m[2].trim()
369
+ }
370
+ const sec = []
371
+ for (; k < lines.length; k++) { if (/^Resume: `/.test(lines[k])) break; sec.push(lines[k]) }
372
+ Object.assign(cp, parseSections(sec))
373
+ return cp
374
+ }
375
+
376
+ // ---------------------------------------------------------------- session markdown
377
+
378
+ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo', 'transcript_path', 'model', 'surface', 'capture',
379
+ 'status', 'started_at', 'last_activity_at', 'last_heartbeat_at', 'ended_at', 'end_reason', 'turns',
380
+ 'last_checkpoint_id', 'last_checkpoint_at', 'parent_session_id', 'pinned', 'archived', 'ignored', 'schema']
381
+
382
+ // Fields persisted so status survives a daemon restart (beyond the spec's Session JSON).
383
+ export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
384
+ 'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
385
+ 'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
386
+ 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason']
387
+
388
+ export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
389
+ 'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
390
+ 'lastCheckpointAt', 'parentSessionId', 'pinned', 'archived', 'ignored']
391
+
392
+ export function renderSessionMd(session, checkpoints = []) {
393
+ const fm = {}
394
+ for (const k of SESSION_FIELDS) fm[camelToSnake(k)] = session[k] === undefined ? null : session[k]
395
+ if (fm.repo && typeof fm.repo === 'object') fm.repo = toSnake(fm.repo)
396
+ for (const k of SESSION_EXTRA) {
397
+ if (k === 'folder' || k === 'statusChanges') continue
398
+ if (session[k] !== undefined && session[k] !== null && session[k] !== '' && session[k] !== 0 && session[k] !== false) fm[camelToSnake(k)] = session[k]
399
+ }
400
+ fm.schema = SCHEMA
401
+ const sorted = checkpoints.slice().sort((a, b) => ms(b.createdAt) - ms(a.createdAt))
402
+ const lines = ['# ' + (session.title || 'Untitled session'), '']
403
+ const repo = repoName(session)
404
+ const branch = session.repo && session.repo.branch
405
+ lines.push('`' + repo + (branch ? ' · ' + branch : '') + '` · ' + (session.machine ? session.machine + ' · ' : '') + (session.status || 'unknown') + ' · started ' + (toIso(session.startedAt) || '?'))
406
+ lines.push('')
407
+ lines.push('Resume: `' + resumeCommand(session) + '`')
408
+ lines.push('')
409
+ lines.push('## Checkpoints')
410
+ lines.push('')
411
+ if (!sorted.length) lines.push('_None yet._')
412
+ for (const cp of sorted) {
413
+ const when = toIso(cp.createdAt).replace('T', ' ').slice(0, 16) + 'Z'
414
+ lines.push('- [' + when + ' · ' + cp.trigger + '](checkpoints/' + (cp.file || checkpointFileName(cp.createdAt)) + ') — Next: ' + clip(cp.next, 160))
415
+ }
416
+ return toFrontmatter(fm, SESSION_ORDER) + lines.join('\n') + '\n'
417
+ }
418
+
419
+ export function parseSessionMd(text) {
420
+ const { data } = parseFrontmatter(text)
421
+ const s = {}
422
+ for (const [k, v] of Object.entries(data)) {
423
+ if (k === 'schema') continue
424
+ s[snakeToCamel(k)] = v
425
+ }
426
+ if (s.repo && typeof s.repo === 'object') s.repo = toCamel(s.repo)
427
+ if (s.repo && typeof s.repo === 'string') s.repo = { root: null, name: s.repo }
428
+ for (const k of ['startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'lastCheckpointAt', 'lastEventAt', 'dormantStretch', 'closedAt', 'pidStartedAt']) {
429
+ if (s[k]) s[k] = toIso(s[k])
430
+ }
431
+ s.turns = typeof s.turns === 'number' ? s.turns : 0
432
+ s.turnsSinceCheckpoint = typeof s.turnsSinceCheckpoint === 'number' ? s.turnsSinceCheckpoint : 0
433
+ s.backgroundTasks = typeof s.backgroundTasks === 'number' ? s.backgroundTasks : 0
434
+ s.pinned = !!s.pinned
435
+ s.archived = !!s.archived
436
+ return s
437
+ }
438
+
439
+ // ---------------------------------------------------------------- tab titles and terminal identity (v0.4)
440
+ //
441
+ // The tab marker is "cp:<6 chars of the session id>". Before 0.5.5 the mod put
442
+ // it in every terminal tab title so `leftoff go` could find the tab by title;
443
+ // now it's added only when Go can't tell the tab apart by tty, name or folder. Claude Code repaints the title itself
444
+ // (✳/◐ + the session's name) about once a second while it works, so the mod
445
+ // sets the *session's* name with Claude Code's own /rename and lets Claude Code
446
+ // draw it; a raw OSC 2 write would be painted over within a second.
447
+
448
+ export function shortId(id) { return id8(id).slice(0, 6) }
449
+ export function tabMarker(id) { return 'cp:' + shortId(id) }
450
+
451
+ const MARKER_RE = /\s*(?:·|-|\|)?\s*cp:([a-z0-9]{6})\s*$/i
452
+
453
+ // "auth refactor · cp:7a1e0b" -> { name: "auth refactor", id: "7a1e0b" }; no marker -> { name, id: null }
454
+ export function parseTabTitle(title) {
455
+ const t = String(title == null ? '' : title)
456
+ const m = t.match(MARKER_RE)
457
+ if (!m) return { name: t.trim(), id: null }
458
+ return { name: t.slice(0, m.index).trim(), id: m[1].toLowerCase() }
459
+ }
460
+ export function stripTabMarker(title) { return parseTabTitle(title).name }
461
+ // Since 0.5.5 the mod's own tab title is usually the session's name alone (no
462
+ // marker), clipped to 60 characters: that coming back from Claude Code is the
463
+ // mod's own /rename, not a name you chose.
464
+ export function isOwnTabTitle(t, s) {
465
+ if (!t || !s) return false
466
+ if (parseTabTitle(t).id) return true
467
+ const name = s.title || null
468
+ return !!name && String(t).trim() === formatTabTitle('{name}', { name, id: s.id })
469
+ }
470
+
471
+ // The default before 0.5.5: every tab title carried " · cp:<id>". A config
472
+ // that still says exactly that is treated as the new default ({name} alone).
473
+ export const LEGACY_TAB_TITLE_FORMAT = '{name} · cp:{id}'
474
+ export function effectiveTitleFormat(format) {
475
+ if (typeof format !== 'string' || !format.trim() || format === LEGACY_TAB_TITLE_FORMAT) return DEFAULT_CONFIG.tabTitleFormat
476
+ if (!/\{name\}|\{repo\}|\{id\}/.test(format)) return DEFAULT_CONFIG.tabTitleFormat
477
+ return format
478
+ }
479
+
480
+ // "colorGenie", or "colorGenie · cp:207651" when `marker` is set: Go needs the
481
+ // marker only when it can't tell the tab apart otherwise (an older Ghostty
482
+ // without tty matching, and another open session with the same name or
483
+ // folder). A format with {id} in it always shows the id.
484
+ export function formatTabTitle(format, { name, id, repo, marker = false } = {}) {
485
+ const fmt = effectiveTitleFormat(format)
486
+ const clean = clip(stripTabMarker(name || '') || repo || 'claude', 60).replace(/[\x00-\x1f\x7f]/g, ' ')
487
+ let out = fmt.replace(/\{name\}/g, clean).replace(/\{repo\}/g, String(repo || '').replace(/[\x00-\x1f\x7f]/g, ' ')).replace(/\{id\}/g, shortId(id)).replace(/\s+/g, ' ').trim()
488
+ if (marker && id && !fmt.includes('{id}')) out += ' · ' + tabMarker(id)
489
+ return out
490
+ }
491
+
492
+ // The environment variables that identify which terminal (and which tab or
493
+ // pane in it) a session runs in. Read once at session start.
494
+ export const TERM_ENV_KEYS = [
495
+ 'TERM_PROGRAM', 'TERM_PROGRAM_VERSION', 'TERM', 'GHOSTTY_RESOURCES_DIR', 'ITERM_SESSION_ID',
496
+ 'WEZTERM_PANE', 'WEZTERM_UNIX_SOCKET', 'KITTY_WINDOW_ID', 'KITTY_LISTEN_ON', 'TMUX', 'TMUX_PANE', 'TERM_SESSION_ID',
497
+ ]
498
+
499
+ const LABELS = { ghostty: 'Ghostty', iterm2: 'iTerm2', wezterm: 'WezTerm', kitty: 'kitty', 'apple-terminal': 'Terminal.app', tmux: 'tmux', vscode: 'VS Code', unknown: 'unknown terminal' }
500
+
501
+ function outerTerminal(env) {
502
+ const tp = String(env.TERM_PROGRAM || '')
503
+ const term = String(env.TERM || '')
504
+ const t = tp.toLowerCase()
505
+ if (t === 'ghostty') return 'ghostty'
506
+ if (t === 'iterm.app') return 'iterm2'
507
+ if (t === 'wezterm') return 'wezterm'
508
+ if (t === 'apple_terminal') return 'apple-terminal'
509
+ if (t === 'vscode') return 'vscode'
510
+ if (t === 'kitty' || env.KITTY_WINDOW_ID || term === 'xterm-kitty') return 'kitty'
511
+ if (env.WEZTERM_PANE) return 'wezterm'
512
+ if (env.ITERM_SESSION_ID) return 'iterm2'
513
+ if (env.GHOSTTY_RESOURCES_DIR || term === 'xterm-ghostty') return 'ghostty'
514
+ return 'unknown'
515
+ }
516
+
517
+ // env (any subset of TERM_ENV_KEYS) -> { kind, label, program, version, ...ids, host? }
518
+ export function detectTerminal(env = {}) {
519
+ const e = env || {}
520
+ const out = { kind: 'unknown', label: '', program: e.TERM_PROGRAM || null, version: e.TERM_PROGRAM_VERSION || null }
521
+ const outer = outerTerminal(e)
522
+ const ids = {}
523
+ if (e.ITERM_SESSION_ID) { const s = String(e.ITERM_SESSION_ID); ids.itermSessionId = s.includes(':') ? s.slice(s.indexOf(':') + 1) : s }
524
+ if (e.WEZTERM_PANE) ids.weztermPane = String(e.WEZTERM_PANE)
525
+ if (e.WEZTERM_UNIX_SOCKET) ids.weztermSocket = String(e.WEZTERM_UNIX_SOCKET)
526
+ if (e.KITTY_WINDOW_ID) ids.kittyWindowId = String(e.KITTY_WINDOW_ID)
527
+ if (e.KITTY_LISTEN_ON) ids.kittyListenOn = String(e.KITTY_LISTEN_ON)
528
+ if (e.TERM_SESSION_ID) ids.termSessionId = String(e.TERM_SESSION_ID)
529
+ if (e.TMUX && e.TMUX_PANE) {
530
+ out.kind = 'tmux'
531
+ out.tmuxPane = String(e.TMUX_PANE)
532
+ out.tmuxSocket = String(e.TMUX).split(',')[0] || null
533
+ out.host = outer === 'unknown' ? null : outer
534
+ out.label = 'tmux' + (out.host ? ' in ' + LABELS[out.host] : '')
535
+ return { ...out, ...ids }
536
+ }
537
+ out.kind = outer
538
+ out.label = outer === 'unknown' ? (e.TERM_PROGRAM || e.TERM || 'unknown terminal') : LABELS[outer]
539
+ // Where Ghostty.app lives, so Go can read its dictionary (does it have tty?) from the bundle.
540
+ if (outer === 'ghostty' && e.GHOSTTY_RESOURCES_DIR) out.resourcesDir = String(e.GHOSTTY_RESOURCES_DIR)
541
+ if (outer === 'vscode' && /cursor/i.test(String(e.TERM_PROGRAM_VERSION || ''))) out.label = 'Cursor'
542
+ return { ...out, ...ids }
543
+ }
544
+
545
+ // `ps -o etime=` ("[[dd-]hh:]mm:ss") -> seconds, or null
546
+ export function parseEtime(s) {
547
+ const m = String(s || '').trim().match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/)
548
+ if (!m) return null
549
+ return (Number(m[1] || 0) * 86400) + (Number(m[2] || 0) * 3600) + Number(m[3]) * 60 + Number(m[4])
550
+ }
551
+
552
+ // The tty column of `ps -o tty=` -> a device path, or null ("?", "??" = none)
553
+ export function ttyPath(t) {
554
+ const s = String(t || '').trim()
555
+ if (!s || /^\?+$/.test(s) || s === '-') return null
556
+ if (s.startsWith('/dev/')) return s
557
+ return '/dev/' + (/^s\d+$/.test(s) ? 'tty' + s : s) // macOS `ps -o tt` prints "s003" for /dev/ttys003
558
+ }
559
+
560
+ // One line from the mod's process probe: "<pid> <tty> <etime>" -> { pid, tty, pidStartedAt }
561
+ export function parseProcLine(line, now = Date.now()) {
562
+ const parts = String(line || '').trim().split(/\s+/)
563
+ const pid = Number(parts[0])
564
+ if (!Number.isInteger(pid) || pid <= 1) return null
565
+ const secs = parseEtime(parts[2])
566
+ return { pid, tty: ttyPath(parts[1]), pidStartedAt: secs == null ? null : toIso(now - secs * 1000) }
567
+ }
568
+
569
+ // ---------------------------------------------------------------- status and dormancy (§6.5)
570
+
571
+ const MIN = 60000
572
+
573
+ export function computeStatus(s, now, cfg = DEFAULT_CONFIG) {
574
+ if (s.endedAt) return 'ended'
575
+ const lastActivity = ms(s.lastActivityAt || s.startedAt)
576
+ const idle = isNaN(lastActivity) ? 0 : now - lastActivity
577
+ const dormantAfter = cfg.dormantAfterMin * MIN
578
+ if (s.capture === 'mod') {
579
+ const hb = ms(s.lastHeartbeatAt || s.lastEventAt || s.startedAt)
580
+ // A Claude Code process the daemon saw running recently isn't stale, even
581
+ // if the mod's heartbeat lapsed (the laptop slept, the mod was slow).
582
+ const seen = ms(s.pidAliveAt)
583
+ const pidFresh = !isNaN(seen) && now - seen <= Math.max(cfg.staleAfterSec * 1000, 90000)
584
+ if (!pidFresh && !isNaN(hb) && now - hb > cfg.staleAfterSec * 1000) return 'stale'
585
+ }
586
+ if (s.needsInput) return 'needs_input'
587
+ if (s.inTurn) {
588
+ // Hooks-only capture sees no event when a turn is interrupted (Stop does not
589
+ // fire), so a turn that has been silent for twice the dormancy window is
590
+ // treated as over. The mod sees turn.complete for aborted turns too.
591
+ if (s.capture === 'hooks' && idle >= 2 * dormantAfter) return 'dormant'
592
+ return 'active'
593
+ }
594
+ if (s.backgroundTasks > 0) return 'background'
595
+ return idle >= dormantAfter ? 'dormant' : 'waiting'
596
+ }
597
+
598
+ // Returns the trigger a checkpoint is due for now, or null.
599
+ export function checkpointDue(s, now, cfg = DEFAULT_CONFIG) {
600
+ if (s.ignored) return null
601
+ const status = computeStatus(s, now, cfg)
602
+ const lastCp = ms(s.lastCheckpointAt)
603
+ const lastActivity = ms(s.lastActivityAt || s.startedAt)
604
+ const gapOk = isNaN(lastCp) || now - lastCp >= cfg.minGapMin * MIN
605
+ const activitySinceCp = (s.turnsSinceCheckpoint || 0) > 0 || (!isNaN(lastActivity) && (isNaN(lastCp) ? !!s.lastActivityAt : lastActivity > lastCp))
606
+ if (status === 'ended' || status === 'stale') {
607
+ if (s.finalCheckpointDone || !activitySinceCp || !gapOk) return null
608
+ if (!s.lastActivityAt) return null
609
+ return s.endReason === 'clear' ? 'clear' : 'session_end'
610
+ }
611
+ const idle = now - lastActivity
612
+ if (idle < cfg.dormantAfterMin * MIN) {
613
+ if (cfg.longRunCheckpointMin && status === 'active' && s.activeSince) {
614
+ const ref = Math.max(ms(s.activeSince), isNaN(lastCp) ? 0 : lastCp)
615
+ if (now - ref >= cfg.longRunCheckpointMin * MIN && gapOk) return 'long_run'
616
+ }
617
+ return null
618
+ }
619
+ if (s.dormantStretch && s.dormantStretch === s.lastActivityAt) return null // one per idle stretch
620
+ if (!gapOk) return null
621
+ if (status === 'dormant' && (s.turnsSinceCheckpoint || 0) >= 1) return 'dormant'
622
+ if (status === 'needs_input' && activitySinceCp) return 'dormant'
623
+ return null
624
+ }
625
+
626
+ // Applies one wire event to a session record (mutates and returns it).
627
+ // Shared so the mod's own fallback registry and the daemon agree.
628
+ // v0.4.8: a session Leftoff knows nothing about yet: no turn, no name, no
629
+ // checkpoint, and not a resumed conversation. The board folds these into an
630
+ // "N empty sessions" row until their first prompt; the digest and restore skip them.
631
+ export function isEmptySession(s, checkpointCount = 0) {
632
+ if (!s) return true
633
+ return !(Number(s.turns) > 0) && !s.title && !(Number(checkpointCount) > 0) && !s.lastCheckpointId &&
634
+ !(Number(s.historyTurns) > 0) && s.startSource !== 'resume'
635
+ }
636
+
637
+ // The shape of the 0.4.6 "Untitled session" card: empty, and no folder or
638
+ // process either. Before 0.4.8 a daemon restart forgot an open session that
639
+ // had no turns yet, and the mod's next heartbeat made a bare record like this.
640
+ export function isGhostSession(s, checkpointCount = 0) {
641
+ return isEmptySession(s, checkpointCount) && !s.cwd && !(Number(s.pid) > 1)
642
+ }
643
+
644
+ export function applyEvent(s, ev) {
645
+ const at = toIso(ev.at) || toIso(Date.now())
646
+ const d = ev.data || {}
647
+ s.id = s.id || ev.sessionId
648
+ s.capture = ev.capture || s.capture || 'hooks'
649
+ if (ev.machine && !s.machine) s.machine = machineSlug(ev.machine)
650
+ s.startedAt = s.startedAt || at
651
+ s.lastEventAt = at
652
+ const activity = () => { s.lastActivityAt = at; s.finalCheckpointDone = false; if (s.endedAt && ev.type !== 'session.ended') { s.endedAt = null; s.endReason = null; s.closedAt = null } }
653
+ if (d.cwd) s.cwd = d.cwd
654
+ if (d.transcriptPath) s.transcriptPath = d.transcriptPath
655
+ if (d.model) s.model = d.model
656
+ if (d.surface) s.surface = d.surface
657
+ if (d.repo && typeof d.repo === 'object') s.repo = { ...(s.repo || {}), ...stripNulls(d.repo) }
658
+ // A title carrying our own tab marker came from the mod's /rename: it is the
659
+ // tab label, not a name the user chose, so it never overwrites the title.
660
+ if (d.sessionTitle && !parseTabTitle(d.sessionTitle).id && d.sessionTitle !== s.title && !isOwnTabTitle(d.sessionTitle, s)) { s.title = d.sessionTitle; s.titleSource = 'custom' }
661
+ if (d.proc && typeof d.proc === 'object' && Number(d.proc.pid) > 1) {
662
+ s.pid = Number(d.proc.pid)
663
+ s.tty = d.proc.tty || null
664
+ s.pidStartedAt = d.proc.pidStartedAt || null
665
+ s.pidSource = d.proc.source || (s.capture === 'mod' ? 'mod' : 'hook')
666
+ }
667
+ if (d.term && typeof d.term === 'object') s.term = d.term
668
+ else if (d.termEnv && typeof d.termEnv === 'object') s.term = detectTerminal(d.termEnv)
669
+ switch (ev.type) {
670
+ case 'session.opened':
671
+ s.lastHeartbeatAt = at
672
+ if (s.endedAt) { s.endedAt = null; s.endReason = null; s.closedAt = null } // reopened (claude --resume) in a new process
673
+ break
674
+ case 'session.started':
675
+ if (d.parentSessionId && d.parentSessionId !== s.id) s.parentSessionId = d.parentSessionId
676
+ if (d.source === 'resume' || d.source === 'fork' || d.source === 'clear') { s.endedAt = null; s.endReason = null; s.closedAt = null }
677
+ if (d.source === 'resume') s.startSource = 'resume' // a conversation that already had turns before Leftoff saw this process
678
+ if (s.capture === 'mod') s.lastHeartbeatAt = at
679
+ break
680
+ case 'activity':
681
+ activity()
682
+ if (d.kind === 'tool' && d.phase === 'post' && s.needsInput && s.lastNotification === 'permission_prompt') s.needsInput = false
683
+ if (d.kind === 'prompt') {
684
+ s.needsInput = false
685
+ s.question = null
686
+ s.lastNotification = null
687
+ if (d.prompt && !s.firstPrompt) s.firstPrompt = clip(d.prompt, 300)
688
+ if (s.capture === 'hooks') { s.inTurn = true; s.activeSince = s.activeSince || at }
689
+ }
690
+ break
691
+ case 'turn_started':
692
+ activity()
693
+ s.inTurn = true
694
+ s.needsInput = false
695
+ s.question = null
696
+ s.activeSince = s.activeSince || at
697
+ break
698
+ case 'turn_completed':
699
+ activity()
700
+ s.inTurn = false
701
+ s.activeSince = null
702
+ s.needsInput = false
703
+ s.turns = (s.turns || 0) + 1
704
+ s.turnsSinceCheckpoint = (s.turnsSinceCheckpoint || 0) + 1
705
+ s.backgroundTasks = Number(d.backgroundTasks || 0) + Number(d.sessionCrons || 0)
706
+ if (d.lastAssistantPreview) s.lastPreview = clip(d.lastAssistantPreview, 280)
707
+ // Claude ended the turn on a question to you (v0.4.4): the board offers a reply.
708
+ s.question = d.question ? clip(d.question, 400) : null
709
+ s.questionAt = d.question ? at : null
710
+ break
711
+ case 'notification':
712
+ s.lastNotification = d.notificationType || null
713
+ s.lastNotificationMessage = d.message ? clip(d.message, 200) : null
714
+ if (NEEDS_INPUT_NOTIFICATIONS.includes(d.notificationType) || /^elicitation/.test(d.notificationType || '')) {
715
+ if (!s.needsInput) s.needsInputAt = at
716
+ s.needsInput = true
717
+ }
718
+ break
719
+ case 'title_changed':
720
+ if (d.title && stripTabMarker(d.title)) { s.title = stripTabMarker(d.title); s.titleSource = d.titleSource || 'custom' }
721
+ break
722
+ case 'heartbeat':
723
+ s.lastHeartbeatAt = at
724
+ if (typeof d.inTurn === 'boolean' && !d.inTurn && s.inTurn && s.capture === 'mod') s.inTurn = false
725
+ break
726
+ case 'session.ended':
727
+ s.endedAt = at
728
+ s.endReason = d.reason || 'other'
729
+ s.inTurn = false
730
+ break
731
+ }
732
+ return s
733
+ }
734
+
735
+ // The question Claude ended its turn on, if it ended on one: the last paragraph
736
+ // of the answer when its last sentence ends with "?" (markdown and closing
737
+ // quotes ignored). Code blocks don't count. -> string or null
738
+ export function trailingQuestion(text) {
739
+ let t = String(text == null ? '' : text).replace(/\r\n?/g, '\n').trim()
740
+ if (!t) return null
741
+ if (/```\s*$/.test(t)) return null
742
+ const paras = t.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean)
743
+ let last = paras[paras.length - 1] || ''
744
+ const bare = last.replace(/[\s*_`)\]"'”’»]+$/u, '')
745
+ if (!/[??]$/.test(bare)) return null
746
+ // A list of options under a lead-in question: keep the lead-in with them.
747
+ if (paras.length > 1 && /^([-*•]|\d+[.)])\s/.test(last) && /[?:]\s*$/.test(paras[paras.length - 2])) last = paras[paras.length - 2] + '\n' + last
748
+ last = last.replace(/^#+\s*/, '').replace(/[*_`]+$/, '')
749
+ return last.length > 400 ? '…' + last.slice(-399) : last
750
+ }
751
+
752
+ function stripNulls(o) { const r = {}; for (const [k, v] of Object.entries(o)) if (v != null) r[k] = v; return r }
753
+
754
+ // ---------------------------------------------------------------- checkpoint input (§7.1)
755
+
756
+ export function compileRedactions(patterns = []) {
757
+ const out = []
758
+ for (const p of patterns) {
759
+ try {
760
+ let src = String(p), flags = 'g'
761
+ if (src.startsWith('(?i)')) { src = src.slice(4); flags = 'gi' }
762
+ out.push(new RegExp(src, flags))
763
+ } catch { /* bad pattern: skipped */ }
764
+ }
765
+ return out
766
+ }
767
+
768
+ export function redact(text, patterns) {
769
+ let s = String(text == null ? '' : text)
770
+ for (const re of compileRedactions(patterns)) s = s.replace(re, '[REDACTED]')
771
+ return s
772
+ }
773
+
774
+ const EDIT_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
775
+
776
+ function relPath(p, cwd) {
777
+ const s = String(p || '')
778
+ if (cwd && s.startsWith(cwd.replace(/\/+$/, '') + '/')) return s.slice(cwd.replace(/\/+$/, '').length + 1)
779
+ return s
780
+ }
781
+
782
+ export function toolTarget(tool, input = {}, cwd) {
783
+ const i = input || {}
784
+ if (i.file_path) return relPath(i.file_path, cwd)
785
+ if (i.notebook_path) return relPath(i.notebook_path, cwd)
786
+ if (i.command) return clip(i.command, 80)
787
+ if (i.pattern) return clip(i.pattern, 60)
788
+ if (i.url) return clip(i.url, 80)
789
+ if (i.path) return relPath(i.path, cwd)
790
+ if (i.description) return clip(i.description, 60)
791
+ return ''
792
+ }
793
+
794
+ export function filesTouched(messages, cwd) {
795
+ const seen = []
796
+ for (const m of messages || []) {
797
+ for (const t of m.toolUses || []) {
798
+ const name = t.tool || t.name
799
+ const fp = t.input && (t.input.file_path || t.input.notebook_path)
800
+ if (EDIT_TOOLS.includes(name) && fp) {
801
+ const r = relPath(fp, cwd)
802
+ if (!seen.includes(r)) seen.push(r)
803
+ }
804
+ }
805
+ }
806
+ return seen
807
+ }
808
+
809
+ // messages: [{ role, text, toolUses: [{ tool, input, isError, text }], toolResults? }]
810
+ // (the shape of $.session.messages(); the daemon's transcript parser produces the same)
811
+ export function transcriptSlice(messages, { maxInputChars = 24000, cwd } = {}) {
812
+ const blocks = []
813
+ let total = 0, lastErrorTaken = false
814
+ for (let i = (messages || []).length - 1; i >= 0; i--) {
815
+ const m = messages[i]
816
+ const lines = []
817
+ if (m.role === 'user') {
818
+ if (m.text && m.text.trim()) lines.push('USER: ' + m.text.trim())
819
+ if (!lastErrorTaken) {
820
+ const err = (m.toolResults || []).find((r) => r.isError)
821
+ if (err) { lines.push('LAST ERROR: ' + clip(err.text, 500)); lastErrorTaken = true }
822
+ }
823
+ } else {
824
+ if (m.text && m.text.trim()) lines.push('ASSISTANT: ' + clip(m.text, 1500))
825
+ for (const t of m.toolUses || []) {
826
+ const name = t.tool || t.name
827
+ let line = ' -> ' + name + (toolTarget(name, t.input, cwd) ? ' ' + toolTarget(name, t.input, cwd) : '')
828
+ if (t.isError) {
829
+ line += ' -> error'
830
+ if (!lastErrorTaken && t.text) { line += ': ' + clip(t.text, 500); lastErrorTaken = true }
831
+ }
832
+ lines.push(line)
833
+ }
834
+ }
835
+ if (!lines.length) continue
836
+ const block = lines.join('\n')
837
+ if (total + block.length > maxInputChars && blocks.length) break
838
+ blocks.push(block)
839
+ total += block.length + 1
840
+ }
841
+ return blocks.reverse().join('\n')
842
+ }
843
+
844
+ // ---------------------------------------------------------------- checkpoint length (v0.4.6)
845
+ //
846
+ // config summary.length picks how much a checkpoint says. Every length keeps the
847
+ // three lines (Doing / Stopped at / Next) the board, the pane and the digest use;
848
+ // the longer ones add sections below them. `/cp --short` / `/cp --long` override
849
+ // the setting for one checkpoint.
850
+ export const SUMMARY_LENGTHS = {
851
+ short: { label: 'Short', maxTokens: 300, approx: '~300 tokens', blurb: 'Doing / Stopped at / Next. Three lines, nothing else.' },
852
+ informative: { label: 'Informative', maxTokens: 700, approx: '~700 tokens', blurb: 'The three lines, plus key decisions, open questions, files touched and why, and what didn\'t work.' },
853
+ long: { label: 'Long', maxTokens: 1500, approx: '~1500 tokens', blurb: 'Everything in Informative, plus a short narrative recap and the next 3 steps.' },
854
+ }
855
+ export const SUMMARY_LENGTH_NAMES = Object.keys(SUMMARY_LENGTHS)
856
+ export const DEFAULT_SUMMARY_LENGTH = 'informative'
857
+
858
+ // 'Long' / 'medium' / 'default' / junk -> one of SUMMARY_LENGTH_NAMES (or null for junk when strict).
859
+ export function normalizeLength(v, { strict = false } = {}) {
860
+ const k = String(v == null ? '' : v).trim().toLowerCase()
861
+ if (SUMMARY_LENGTHS[k]) return k
862
+ const alias = { s: 'short', brief: 'short', i: 'informative', info: 'informative', medium: 'informative', normal: 'informative', default: 'informative', l: 'long', full: 'long', detailed: 'long' }[k]
863
+ if (alias) return alias
864
+ return strict ? null : DEFAULT_SUMMARY_LENGTH
865
+ }
866
+ export function summaryLength(cfg = DEFAULT_CONFIG, override) {
867
+ if (override) { const o = normalizeLength(override, { strict: true }); if (o) return o }
868
+ return normalizeLength(cfg && cfg.summary && cfg.summary.length)
869
+ }
870
+
871
+ // "/cp --long fix the flaky test" -> { length: 'long', note: 'fix the flaky test' }.
872
+ // Recognised anywhere in the args: --short, --informative, --long, --length=<x>,
873
+ // -s / -l. Everything else is the note, as typed.
874
+ export function parseCheckpointArgs(args) {
875
+ let length = null
876
+ const rest = []
877
+ for (const w of String(args || '').trim().split(/\s+/).filter(Boolean)) {
878
+ const m = w.match(/^--length=(\w+)$/i)
879
+ if (m && normalizeLength(m[1], { strict: true })) { length = normalizeLength(m[1], { strict: true }); continue }
880
+ const f = w.match(/^--?(short|informative|info|long|s|l)$/i)
881
+ if (f) { length = normalizeLength(f[1], { strict: true }); continue }
882
+ rest.push(w)
883
+ }
884
+ return { length, note: rest.join(' ') || null }
885
+ }
886
+
887
+ const CHECKPOINT_BASE = [
888
+ 'You write checkpoints for a developer juggling many coding-agent sessions. Given the recent transcript of ONE session, start with exactly these three lines:',
889
+ 'Doing: <what the session was working on, one sentence, concrete nouns: files, features, errors>',
890
+ 'Stopped at: <the exact state when it went quiet: what passed, what failed, what is half-done>',
891
+ 'Next: <the single next action a human or the agent should take>',
892
+ 'If the session is blocked on the human (a question, a permission, a decision), start Next with "YOU:".',
893
+ ]
894
+ const INFORMATIVE_SECTIONS = [
895
+ 'Then these sections, each a heading line followed by "- " bullets (write "- none" when there is nothing; never invent):',
896
+ 'Decisions:',
897
+ '- <a decision made in the session and the reason, one line each, at most 4>',
898
+ 'Open questions:',
899
+ '- <an unresolved question or uncertainty, at most 3>',
900
+ 'Files:',
901
+ '- <path> — <why it was touched, a few words; at most 6 files>',
902
+ 'Didn\'t work:',
903
+ '- <an approach that was tried and abandoned or failed, and why; at most 3>',
904
+ ]
905
+ const LONG_SECTIONS = [
906
+ 'Then:',
907
+ 'Recap: <a narrative paragraph of 4-7 sentences: the goal, how the session got here, the current state>',
908
+ 'Next steps:',
909
+ '1. <the next step>',
910
+ '2. <the step after>',
911
+ '3. <the step after that>',
912
+ ]
913
+ export const CHECKPOINT_SYSTEMS = {
914
+ short: [...CHECKPOINT_BASE, 'Write only those three lines.', 'Optionally a 4th line "Title: <max 6 words>" if asked for a title.', 'No preamble. No markdown. Under 60 words total.'].join('\n'),
915
+ informative: [...CHECKPOINT_BASE, ...INFORMATIVE_SECTIONS, 'Optionally a final line "Title: <max 6 words>" if asked for a title.', 'No preamble. No other markdown. Under 250 words total.'].join('\n'),
916
+ long: [...CHECKPOINT_BASE, ...INFORMATIVE_SECTIONS, ...LONG_SECTIONS, 'Optionally a final line "Title: <max 6 words>" if asked for a title.', 'No preamble. No other markdown. Under 600 words total.'].join('\n'),
917
+ }
918
+ // The short prompt, as before v0.4.6 (kept under its old name for anything importing it).
919
+ export const CHECKPOINT_SYSTEM = CHECKPOINT_SYSTEMS.short
920
+
921
+ export function buildCheckpointPrompt({ session, previous, messages, cfg = DEFAULT_CONFIG, wantTitle = false, userNote, length }) {
922
+ const len = summaryLength(cfg, length)
923
+ const maxInputChars = (cfg.summarizer && cfg.summarizer.maxInputChars) || 24000
924
+ const cwd = session.cwd
925
+ const files = filesTouched(messages, cwd)
926
+ const branch = session.repo && session.repo.branch
927
+ const prev = previous ? ['Doing: ' + previous.doing, 'Stopped at: ' + previous.stoppedAt, 'Next: ' + previous.next].join('\n') : 'none'
928
+ const head = [
929
+ 'Repo: ' + repoName(session) + (branch ? ' (branch ' + branch + ')' : '') + ' · cwd ' + (cwd || '?'),
930
+ 'Title: ' + (session.title || '(none yet)'),
931
+ 'Previous checkpoint: ' + prev,
932
+ 'Files touched since then: ' + (files.length ? files.join(', ') : 'none'),
933
+ ]
934
+ if (userNote) head.push('Note from the developer: ' + userNote)
935
+ if (session.needsInput) head.push('State: the session is waiting on the developer (' + (session.lastNotification || 'a prompt') + ').')
936
+ if (wantTitle) head.push('Also write the line: Title: <max 6 words>.')
937
+ const slice = transcriptSlice(messages, { maxInputChars, cwd })
938
+ const patterns = (cfg.redact && cfg.redact.patterns) || []
939
+ const prompt = redact(head.join('\n') + '\nTranscript (oldest→newest, trimmed):\n' + (slice || '(empty)'), patterns)
940
+ return { system: CHECKPOINT_SYSTEMS[len], prompt, filesTouched: files, inputChars: prompt.length, length: len, maxTokens: SUMMARY_LENGTHS[len].maxTokens }
941
+ }
942
+
943
+ // The extra sections of an informative / long checkpoint, by the heading a
944
+ // model (or a checkpoint file) uses for them.
945
+ const SECTION_KEYS = {
946
+ 'decisions': 'decisions', 'key decisions': 'decisions',
947
+ 'open questions': 'questions', 'questions': 'questions',
948
+ 'files': 'files', 'files touched': 'files',
949
+ 'didn\'t work': 'didntWork', 'didnt work': 'didntWork', 'did not work': 'didntWork', 'what didn\'t work': 'didntWork', 'what didnt work': 'didntWork', 'tried': 'didntWork',
950
+ 'recap': 'recap',
951
+ 'next steps': 'nextSteps', 'next 3 steps': 'nextSteps',
952
+ }
953
+ export const SECTION_TITLES = { decisions: 'Key decisions', questions: 'Open questions', files: 'Files touched', didntWork: 'What didn\'t work', recap: 'Recap', nextSteps: 'Next steps' }
954
+ const SECTION_ORDER = ['decisions', 'questions', 'files', 'didntWork', 'recap', 'nextSteps']
955
+ const NONE_RE = /^(none|n\/a|nothing|-|—|none yet|no(ne)? (open )?questions?)\.?$/i
956
+ const LIMITS = { decisions: 6, questions: 5, files: 10, didntWork: 5, nextSteps: 5 }
957
+
958
+ // "src/a.js — retry loop" / "`src/a.js`: why" -> { path, why }
959
+ export function parseFileNote(text) {
960
+ const t = String(text || '').trim()
961
+ const m = t.match(/^`?([^`\s][^`]*?)`?\s*(?:—|–|-{1,2}|:)\s+(.+)$/)
962
+ if (m && /[./]/.test(m[1]) && !/\s/.test(m[1].trim())) return { path: m[1].trim(), why: m[2].trim() }
963
+ const p = t.match(/^`?([^`\s]+)`?$/)
964
+ return p ? { path: p[1], why: '' } : { path: '', why: t }
965
+ }
966
+
967
+ function emptyExtras() { return { decisions: [], questions: [], files: [], didntWork: [], recap: '', nextSteps: [] } }
968
+ export function hasExtras(cp) {
969
+ return !!cp && (['decisions', 'questions', 'files', 'didntWork', 'nextSteps'].some((k) => Array.isArray(cp[k]) && cp[k].length) || !!cp.recap)
970
+ }
971
+
972
+ // Section lines (after the three main lines) -> the extras. Shared by the
973
+ // model-reply parser and the checkpoint-file parser.
974
+ function parseSections(lines) {
975
+ const ex = emptyExtras()
976
+ let cur = null
977
+ const push = (k, v) => {
978
+ v = String(v || '').replace(/\*\*/g, '').trim()
979
+ if (!v || NONE_RE.test(v)) return
980
+ if (k === 'recap') { ex.recap = ex.recap ? ex.recap + ' ' + v : v; return }
981
+ if (k === 'files') { const f = parseFileNote(v); if (f.path || f.why) ex.files.push(f); return }
982
+ ex[k].push(v)
983
+ }
984
+ for (const raw of lines) {
985
+ const line = String(raw).trim()
986
+ if (!line) continue
987
+ const h = line.replace(/^#+\s*/, '').replace(/\*\*/g, '').replace(/^_+|_+$/g, '').trim()
988
+ const hm = h.match(/^([A-Za-z' ’0-9]+?)\s*:\s*(.*)$/) || h.match(/^([A-Za-z' ’0-9]+?)$/)
989
+ const key = hm ? SECTION_KEYS[hm[1].replace(/’/g, '\'').trim().toLowerCase()] : null
990
+ if (key && (hm[2] !== undefined || /^#/.test(line) || !cur)) {
991
+ cur = key
992
+ if (hm[2]) push(key, hm[2].replace(/^[-*•]\s*/, ''))
993
+ continue
994
+ }
995
+ if (!cur) continue
996
+ const item = line.match(/^(?:[-*•]|\d+[.)])\s+(.*)$/)
997
+ if (item) push(cur, item[1])
998
+ else if (cur === 'recap') push(cur, line)
999
+ else if (/^(doing|stopped at|next|title|open question|note|resume)\s*:/i.test(h)) cur = null
1000
+ else push(cur, line)
1001
+ }
1002
+ for (const k of Object.keys(LIMITS)) ex[k] = ex[k].slice(0, LIMITS[k])
1003
+ ex.recap = clip(ex.recap, 1600)
1004
+ return ex
1005
+ }
1006
+
1007
+ export function parseCheckpointReply(text) {
1008
+ const out = { doing: '', stoppedAt: '', next: '', title: null, openQuestion: null, parseOk: true }
1009
+ const map = { 'doing': 'doing', 'stopped at': 'stoppedAt', 'next': 'next', 'title': 'title', 'open question': 'openQuestion' }
1010
+ const rest = []
1011
+ for (const raw of String(text || '').split('\n')) {
1012
+ const line = raw.replace(/^[\s*_#>-]+/, '').replace(/\*\*/g, '')
1013
+ const m = line.match(/^(doing|stopped at|next|title|open question)\s*:\s*(.+)$/i)
1014
+ if (m) out[map[m[1].toLowerCase()]] = m[2].trim()
1015
+ else rest.push(raw)
1016
+ }
1017
+ if (!out.doing || !out.stoppedAt || !out.next) {
1018
+ return { doing: clip(text, 600) || '(empty reply)', stoppedAt: out.stoppedAt || '', next: out.next || '', title: out.title, openQuestion: null, parseOk: false, ...emptyExtras() }
1019
+ }
1020
+ if (out.title) out.title = clip(out.title.replace(/^["']|["']$/g, ''), 60)
1021
+ return { ...out, ...parseSections(rest) }
1022
+ }
1023
+
1024
+ // No model available: build a checkpoint from the last prompt and the last reply.
1025
+ // Informative / long add what can be read off the transcript without a model:
1026
+ // the files edited (with the tool), the failed tool calls, the questions asked.
1027
+ export function heuristicCheckpoint(messages, session = {}, { length = 'short', cwd } = {}) {
1028
+ const ms_ = messages || []
1029
+ const lastUser = [...ms_].reverse().find((m) => m.role === 'user' && m.text && m.text.trim())
1030
+ const lastAssistant = [...ms_].reverse().find((m) => m.role === 'assistant' && m.text && m.text.trim())
1031
+ let lastError = null
1032
+ for (let i = ms_.length - 1; i >= 0 && !lastError; i--) {
1033
+ const m = ms_[i]
1034
+ const e1 = (m.toolResults || []).find((r) => r.isError)
1035
+ const e2 = (m.toolUses || []).find((t) => t.isError)
1036
+ if (e1) lastError = e1.text
1037
+ else if (e2) lastError = e2.text || (e2.tool + ' failed')
1038
+ if (m === lastAssistant && !lastError) break
1039
+ }
1040
+ const firstSentence = (t) => { const s = clip(t, 400); const m = s.match(/^(.{20,240}?[.!?])(\s|$)/); return m ? m[1] : clip(s, 240) }
1041
+ const doing = lastUser ? 'Working on: “' + clip(lastUser.text.split('\n')[0], 140) + '”' : (session.firstPrompt ? 'Working on: “' + clip(session.firstPrompt, 140) + '”' : 'No prompt captured yet.')
1042
+ const stoppedAt = lastError ? 'Last error: ' + clip(lastError, 200) : lastAssistant ? firstSentence(lastAssistant.text) : (session.lastPreview ? firstSentence(session.lastPreview) : 'No reply captured yet.')
1043
+ let next = 'Resume the session and continue from the last reply.'
1044
+ const tail = lastAssistant ? clip(lastAssistant.text, 4000).slice(-300) : ''
1045
+ const q = tail.match(/([^.!?\n]{8,200}\?)\s*$/)
1046
+ if (session.needsInput) next = 'YOU: answer the pending ' + String(session.lastNotification || 'prompt').replace(/_/g, ' ') + ' in this session.'
1047
+ else if (q) next = 'YOU: ' + clip(q[1], 180)
1048
+ else if (lastError) next = 'Look at the last error and decide whether to retry or change approach.'
1049
+ const base = { doing, stoppedAt, next, title: null, openQuestion: null, parseOk: true }
1050
+ const len = normalizeLength(length)
1051
+ if (len === 'short') return base
1052
+ const ex = emptyExtras()
1053
+ const dir = cwd || session.cwd
1054
+ const seen = new Map()
1055
+ for (const m of ms_) for (const t of m.toolUses || []) {
1056
+ const name = t.tool || t.name
1057
+ const fp = t.input && (t.input.file_path || t.input.notebook_path)
1058
+ if (EDIT_TOOLS.includes(name) && fp) { const r = relPath(fp, dir); if (!seen.has(r)) seen.set(r, name === 'Write' ? 'written' : 'edited') }
1059
+ if (t.isError && ex.didntWork.length < LIMITS.didntWork) ex.didntWork.push(clip(name + (toolTarget(name, t.input, dir) ? ' ' + toolTarget(name, t.input, dir) : '') + ' failed' + (t.text ? ': ' + t.text.split('\n')[0] : ''), 200))
1060
+ }
1061
+ ex.files = [...seen].slice(-LIMITS.files).map(([path, why]) => ({ path, why }))
1062
+ for (const m of ms_) if (m.role === 'assistant' && m.text) { const qq = trailingQuestion(m.text); if (qq && !ex.questions.includes(qq)) ex.questions.push(clip(qq, 200)) }
1063
+ ex.questions = ex.questions.slice(-LIMITS.questions)
1064
+ if (len === 'long') {
1065
+ const firstUser = ms_.find((m) => m.role === 'user' && m.text && m.text.trim())
1066
+ const prompts = ms_.filter((m) => m.role === 'user' && m.text && m.text.trim()).length
1067
+ ex.recap = [
1068
+ firstUser ? 'The session started with: “' + clip(firstUser.text.split('\n')[0], 160) + '”.' : '',
1069
+ prompts > 1 ? 'There were ' + prompts + ' prompts since.' : '',
1070
+ seen.size ? seen.size + ' file' + (seen.size === 1 ? ' was' : 's were') + ' changed.' : '',
1071
+ lastError ? 'The last tool call failed.' : '',
1072
+ 'Written without a model, from the transcript alone.',
1073
+ ].filter(Boolean).join(' ')
1074
+ ex.nextSteps = [next]
1075
+ }
1076
+ return { ...base, ...ex }
1077
+ }
1078
+
1079
+ // §7.4 fallback title: {repo}@{branch} · {first 6 words of the first prompt}
1080
+ export function fallbackTitle(session) {
1081
+ const words = String(session.firstPrompt || '').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean).slice(0, 6).join(' ')
1082
+ const branch = session.repo && session.repo.branch
1083
+ return repoName(session) + (branch ? '@' + branch : '') + (words ? ' · ' + words : '')
1084
+ }
1085
+
1086
+ export const TITLE_SYSTEM = 'Write a title of at most 6 words for a coding session, based on its first prompt. Reply with the title only, no quotes, no punctuation at the end.'
1087
+
1088
+ export function cleanTitle(text) {
1089
+ const t = String(text || '').split('\n').map((l) => l.trim()).find(Boolean) || ''
1090
+ return clip(t.replace(/^title\s*:\s*/i, '').replace(/^["'`*]+|["'`*.]+$/g, ''), 60)
1091
+ }
1092
+
1093
+ // ---------------------------------------------------------------- catch me up (v0.5.0)
1094
+
1095
+ // A model reply -> its bullet lines (at most max), or null when fewer than two.
1096
+ export function parseBulletList(text, max = 6) {
1097
+ const clean = String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim()
1098
+ const out = []
1099
+ for (const line of clean.split('\n')) {
1100
+ const m = line.match(/^\s*(?:[-*•]|\d+[.)])\s+(.*\S)\s*$/)
1101
+ if (m) out.push(m[1].replace(/\*\*/g, '').trim())
1102
+ }
1103
+ if (out.length < 2) return null
1104
+ return out.slice(0, max)
1105
+ }
1106
+
1107
+ const CATCHUP_WINDOW_WORD = /^(last|since-last|\d+(?:\.\d+)?(?:m|min|h|hr|d|w|wk)|workday|last-workday|today|yesterday|(?:sun|mon|tues?|wed(?:nes)?|thu(?:rs)?|fri|sat(?:ur)?)(?:day)?|\d{4}-\d{2}-\d{2})$/i
1108
+
1109
+ // /cp-catchup [window] [repo ...] [all|here]: "3d leftoff site", "friday", "here".
1110
+ export function parseCatchupArgs(args) {
1111
+ const words = String(args || '').trim().split(/\s+/).filter(Boolean)
1112
+ const o = { since: null, repos: [], here: false, all: false }
1113
+ for (const w of words) {
1114
+ const k = w.replace(/^--?/, '')
1115
+ if (!o.since && CATCHUP_WINDOW_WORD.test(k)) o.since = k
1116
+ else if (/^(all|everything)$/i.test(k)) o.all = true
1117
+ else if (/^(here|this)$/i.test(k)) o.here = true
1118
+ else o.repos.push(...k.split(',').filter(Boolean))
1119
+ }
1120
+ if (o.all) o.repos = []
1121
+ return o
1122
+ }
1123
+
1124
+ // ---------------------------------------------------------------- lists (v0.5.1)
1125
+ //
1126
+ // Checklists Claude writes ("- [ ] …", or numbered / bulleted next steps under
1127
+ // a heading), saved as plain Markdown in lists/<repo>/<slug>.md. Item numbers
1128
+ // are stable: an item keeps its number for good (a hidden "<!-- #3 … -->"
1129
+ // after it), new items get the next one, and nothing is renumbered when items
1130
+ // are ticked or a hand edit moves them.
1131
+
1132
+ const LIST_FENCE = /^\s*(```|~~~)/
1133
+ const LIST_CHECK = /^(\s*)(?:[-*+]|\d{1,3}[.)])\s+\[( |x|X)\]\s+(.*\S)\s*$/
1134
+ const LIST_NUM = /^(\s*)(\d{1,3})[.)]\s+(.*\S)\s*$/
1135
+ const LIST_BUL = /^(\s*)[-*+•]\s+(.*\S)\s*$/
1136
+ const LIST_HEAD = /^\s*(?:#{1,6}\s+(.+?)\s*#*|\*\*(.+?)\*\*\s*:?|__(.+?)__\s*:?|(\S[^\n]{0,100}?)\s*:)\s*$/
1137
+ export const LIST_STEP_WORDS = /\b(need to|you'?ll have to|make sure|in order to|do next|to do|how to|instructions|checklist|next(?: steps?)?|steps?|to-?dos?|tasks?|plan|remaining|follow[- ]?ups?|action items?|check-?list|left to do|what'?s left|still to do|open items?|road ?map|phases?|milestones?|punch ?list|before (?:we|you|i) (?:ship|merge|launch))\b/i
1138
+
1139
+ function listItemText(s) {
1140
+ return String(s || '').replace(/\*\*(.+?)\*\*/g, '$1').replace(/__(.+?)__/g, '$1').replace(/\s*<!--.*?-->\s*$/, '').replace(/\s+/g, ' ').trim()
1141
+ }
1142
+ function listMarker(line) {
1143
+ let m = line.match(LIST_CHECK)
1144
+ if (m) return { indent: m[1].replace(/\t/g, ' ').length, done: m[2] !== ' ', checkbox: true, text: m[3] }
1145
+ m = line.match(LIST_NUM)
1146
+ if (m) return { indent: m[1].replace(/\t/g, ' ').length, done: false, checkbox: false, numbered: true, n: Number(m[2]), text: m[3] }
1147
+ m = line.match(LIST_BUL)
1148
+ if (m && !/^[-*_]{3,}$/.test(line.trim())) return { indent: m[1].replace(/\t/g, ' ').length, done: false, checkbox: false, text: m[2] }
1149
+ return null
1150
+ }
1151
+ export function cleanListHeading(h) {
1152
+ return String(h || '').replace(/^#+\s*/, '').replace(/\*\*|__/g, '').replace(/[::]\s*$/, '').replace(/\s+/g, ' ').trim()
1153
+ }
1154
+
1155
+ // The lists in one message's text, in order. A checklist ("- [ ]" / "- [x]")
1156
+ // always counts; a numbered or bulleted list counts when the heading or lead-in
1157
+ // line just above it names steps, a plan, todos, next steps and the like.
1158
+ // Lists inside code fences are not lists. Nested lines belong to the item above.
1159
+ export function listsInText(text, { minItems = 2 } = {}) {
1160
+ const lines = String(text || '').replace(/\r\n/g, '\n').split('\n')
1161
+ const out = []
1162
+ let inFence = false, heading = null, headingAt = -10
1163
+ for (let i = 0; i < lines.length; i++) {
1164
+ const line = lines[i]
1165
+ if (LIST_FENCE.test(line)) { inFence = !inFence; continue }
1166
+ if (inFence) continue
1167
+ if (!line.trim()) continue
1168
+ const first = listMarker(line)
1169
+ if (!first) {
1170
+ const h = line.match(LIST_HEAD)
1171
+ if (h) { heading = cleanListHeading(h[1] || h[2] || h[3] || h[4]); headingAt = i } else { heading = null }
1172
+ continue
1173
+ }
1174
+ // a list starts here
1175
+ const base = first.indent
1176
+ const items = [{ text: listItemText(first.text), done: first.done, checkbox: first.checkbox, numbered: !!first.numbered }]
1177
+ let j = i + 1
1178
+ for (; j < lines.length; j++) {
1179
+ const l = lines[j]
1180
+ if (LIST_FENCE.test(l)) break
1181
+ if (!l.trim()) {
1182
+ let k = j + 1
1183
+ while (k < lines.length && !lines[k].trim()) k++
1184
+ const nx = k < lines.length ? listMarker(lines[k]) : null
1185
+ if (nx && nx.indent <= base + 1) { j = k - 1; continue }
1186
+ break
1187
+ }
1188
+ const mk = listMarker(l)
1189
+ const ind = l.match(/^\s*/)[0].replace(/\t/g, ' ').length
1190
+ if (mk && mk.indent <= base + 1) { items.push({ text: listItemText(mk.text), done: mk.done, checkbox: mk.checkbox, numbered: !!mk.numbered }); continue }
1191
+ if (ind > base) {
1192
+ // a nested bullet folds into the item above ("Prepare assets — icon; screenshots");
1193
+ // a wrapped line continues it
1194
+ const last = items[items.length - 1]
1195
+ const t = listItemText(mk ? mk.text : l)
1196
+ if (last && t) last.text = mk ? (last.sub ? last.text + '; ' + t : last.text + ' — ' + t) : last.text + ' ' + t
1197
+ if (last && mk) last.sub = true
1198
+ continue
1199
+ }
1200
+ break
1201
+ }
1202
+ const checks = items.filter((x) => x.checkbox).length
1203
+ const near = heading && headingAt >= i - 3 ? heading : null
1204
+ const kind = checks >= Math.ceil(items.length / 2) ? 'checklist' : items.every((x) => x.numbered) ? 'numbered' : 'bulleted'
1205
+ const counts = kind === 'checklist' || (near && LIST_STEP_WORDS.test(near))
1206
+ const real = items.filter((x) => x.text)
1207
+ if (counts && real.length >= minItems) out.push({ kind, heading: near, items: real.map((x) => ({ text: x.text, done: !!x.done })), line: i })
1208
+ heading = null
1209
+ i = j - 1
1210
+ }
1211
+ return out
1212
+ }
1213
+
1214
+ // Every list Claude wrote in a session, oldest first, numbered 1..n.
1215
+ // messages: the $.session.messages() / transcript shape.
1216
+ export function extractChecklists(messages, { minItems = 2, fallback = null } = {}) {
1217
+ const out = []
1218
+ ;(messages || []).forEach((m, mi) => {
1219
+ if (!m || m.role !== 'assistant' || !m.text) return
1220
+ for (const c of listsInText(m.text, { minItems })) out.push({ ...c, message: mi, at: m.at || null })
1221
+ })
1222
+ return out.map((c, k) => ({ ...c, n: k + 1, name: listAutoName(c, { fallback }) }))
1223
+ }
1224
+
1225
+ // How alike two item lists are (0..1): the share of the shorter one's items
1226
+ // the other also has. A list Claude repeats with some boxes ticked is the same list.
1227
+ const normItem = (t) => String(t || '').toLowerCase().replace(/[`*_~]/g, '').replace(/[^a-z0-9]+/g, ' ').trim()
1228
+ export function listSimilarity(a, b) {
1229
+ const A = (a || []).map((x) => normItem(x.text || x)).filter(Boolean), B = new Set((b || []).map((x) => normItem(x.text || x)).filter(Boolean))
1230
+ if (!A.length || !B.size) return 0
1231
+ const hit = A.filter((t) => B.has(t)).length
1232
+ return hit / Math.min(A.length, B.size)
1233
+ }
1234
+ export function listFingerprint(items) {
1235
+ let h = 2166136261
1236
+ for (const ch of (items || []).map((x) => normItem(x.text || x)).join('|')) { h ^= ch.charCodeAt(0); h = Math.imul(h, 16777619) >>> 0 }
1237
+ return h.toString(16).padStart(8, '0')
1238
+ }
1239
+
1240
+ // A list's name from its heading: "Here are the next steps to submit to the
1241
+ // App Store:" -> "Next steps to submit to the App Store". No heading -> the
1242
+ // fallback (the caller passes the session's title), else "Checklist".
1243
+ export function listAutoName(c, { fallback = null } = {}) {
1244
+ let h = cleanListHeading(c && c.heading)
1245
+ h = h.split(/(?<=[.!?])\s+/).pop() // "Build is green. Here are the next steps…" -> the last sentence
1246
+ h = h.replace(/^(?:ok(?:ay)?|sure|great|alright|done)[,.!]?\s+/i, '')
1247
+ h = h.replace(/^(?:so\s+)?(?:here(?:'|’)?s|here is|here are|these are|this is|below (?:is|are))\s+(?:(?:the|my|a|an|your|our|some)\s+)?/i, '')
1248
+ h = h.replace(/^(?:the|my|your|our)\s+/i, '').replace(/[.!]+$/, '').trim()
1249
+ if (h) return clip(h.charAt(0).toUpperCase() + h.slice(1), 60)
1250
+ const f = cleanListHeading(fallback)
1251
+ return f ? clip(f, 48) + ' checklist' : 'Checklist'
1252
+ }
1253
+
1254
+ // The Stop hook's cheap check (no model): did this reply contain a list of 3+?
1255
+ // -> { count, heading, kind, name, items } for the last such list, or null.
1256
+ export function listHintFrom(text, { minItems = 3, maxItems = 60, fallback = null } = {}) {
1257
+ const ls = listsInText(text, { minItems })
1258
+ if (!ls.length) return null
1259
+ const c = ls[ls.length - 1]
1260
+ const items = c.items.slice(0, maxItems).map((x) => ({ text: clip(x.text, 300), done: !!x.done }))
1261
+ return { count: c.items.length, heading: c.heading || null, kind: c.kind, name: listAutoName(c, { fallback }), items, fp: listFingerprint(items) }
1262
+ }
1263
+
1264
+ export function listSlug(s, max = 48) {
1265
+ return String(s || '').toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, max).replace(/-+$/, '') || 'list'
1266
+ }
1267
+
1268
+ const LIST_ORDER = ['name', 'repo', 'repo_key', 'source_session', 'source_machine', 'source_title', 'created', 'updated', 'archived', 'next_id', 'schema']
1269
+ const LIST_ITEM = /^(\s*)[-*+]\s+\[( |x|X)\]\s+(.*?)\s*(?:<!--\s*#(\d+)(?:\s+(\S+))?\s*-->)?\s*$/
1270
+
1271
+ // A list file -> { meta, items: [{ id, text, done, at }], body: [segments] }.
1272
+ // body keeps every line that isn't an item (notes, blank lines) where it was,
1273
+ // so a hand-edited file comes back out the same.
1274
+ export function parseListMd(text) {
1275
+ const { data, body } = parseFrontmatter(text)
1276
+ const meta = {
1277
+ name: data.name == null ? null : String(data.name), repo: data.repo == null ? null : String(data.repo), repoKey: data.repo_key == null ? null : String(data.repo_key),
1278
+ sourceSession: data.source_session || null, sourceMachine: data.source_machine == null ? null : String(data.source_machine), sourceTitle: data.source_title == null ? null : String(data.source_title),
1279
+ created: data.created ? toIso(data.created) : null, updated: data.updated ? toIso(data.updated) : null, archived: data.archived === true, nextId: Number(data.next_id) || 0,
1280
+ }
1281
+ const items = [], segs = []
1282
+ let titleSeen = false
1283
+ for (const raw of String(body || '').replace(/\r\n/g, '\n').replace(/\n+$/, '').split('\n')) {
1284
+ const m = raw.match(LIST_ITEM)
1285
+ if (m && !m[1]) {
1286
+ const it = { id: m[4] ? Number(m[4]) : null, text: m[3].trim(), done: m[2] !== ' ', at: m[5] ? toIso(m[5]) : null }
1287
+ items.push(it); segs.push({ item: it })
1288
+ continue
1289
+ }
1290
+ if (!titleSeen && /^#\s+/.test(raw)) { titleSeen = true; if (!meta.name) meta.name = raw.replace(/^#\s+/, '').trim(); segs.push({ title: true }); continue }
1291
+ segs.push({ line: raw })
1292
+ }
1293
+ return { meta, items, segs }
1294
+ }
1295
+
1296
+ // Give every item a number (hand-added ones get the next free one).
1297
+ export function numberListItems(list) {
1298
+ // nextId is the next number to give out; numbers are never reused
1299
+ let next = Math.max(list.meta.nextId || 1, ...list.items.map((x) => (x.id || 0) + 1))
1300
+ const seen = new Set()
1301
+ for (const it of list.items) {
1302
+ if (!it.id || seen.has(it.id)) it.id = next++
1303
+ seen.add(it.id)
1304
+ }
1305
+ list.meta.nextId = next
1306
+ return list
1307
+ }
1308
+
1309
+ export function renderListMd(list) {
1310
+ numberListItems(list)
1311
+ const m = list.meta
1312
+ const fm = toFrontmatter({
1313
+ name: m.name || 'List', repo: m.repo || null, repo_key: m.repoKey || null, source_session: m.sourceSession || null, source_machine: m.sourceMachine || null,
1314
+ source_title: m.sourceTitle || null, created: m.created || null, updated: m.updated || null, archived: !!m.archived, next_id: m.nextId, schema: SCHEMA,
1315
+ }, LIST_ORDER)
1316
+ const segs = list.segs && list.segs.length ? list.segs.slice() : [{ title: true }, { line: '' }]
1317
+ // items not placed yet (new ones) go after the last item line
1318
+ const placed = new Set(segs.filter((s) => s.item).map((s) => s.item))
1319
+ const fresh = list.items.filter((it) => !placed.has(it))
1320
+ const live = new Set(list.items)
1321
+ let lastItem = -1
1322
+ segs.forEach((s, k) => { if (s.item) lastItem = k })
1323
+ const at = lastItem >= 0 ? lastItem + 1 : segs.length
1324
+ segs.splice(at, 0, ...fresh.map((it) => ({ item: it })))
1325
+ if (!segs.some((s) => s.title)) segs.unshift({ title: true }, { line: '' })
1326
+ const one = (s) => String(s == null ? '' : s).replace(/\s*\n\s*/g, ' ').replace(/<!--/g, '<!-').trim()
1327
+ const lines = []
1328
+ for (const s of segs) {
1329
+ if (s.title) lines.push('# ' + one(m.name || 'List'))
1330
+ else if (s.item) { if (live.has(s.item)) lines.push('- [' + (s.item.done ? 'x' : ' ') + '] ' + one(s.item.text) + ' <!-- #' + s.item.id + (s.item.at ? ' ' + s.item.at : '') + ' -->') }
1331
+ else lines.push(s.line)
1332
+ }
1333
+ return fm + lines.join('\n').replace(/\n+$/, '') + '\n'
1334
+ }
1335
+
1336
+ export function newList({ name, items = [], repo = null, repoKey = null, sourceSession = null, sourceMachine = null, sourceTitle = null, now = new Date().toISOString() }) {
1337
+ const list = { meta: { name: cleanListHeading(name) || 'List', repo, repoKey, sourceSession, sourceMachine, sourceTitle, created: toIso(now), updated: toIso(now), archived: false, nextId: 0 }, items: [], segs: null }
1338
+ for (const it of items) if (it && String(it.text || '').trim()) list.items.push({ id: null, text: listItemText(it.text), done: !!it.done, at: it.done ? toIso(now) : null })
1339
+ return numberListItems(list)
1340
+ }
1341
+
1342
+ // Two copies of one list (a sync conflict, or a hand edit racing the board):
1343
+ // every item from either survives; for an item both have, the later change
1344
+ // (its "at") wins; the name and archived flag come from the copy updated last.
1345
+ export function mergeLists(a, b) {
1346
+ const newer = (ms(b.meta.updated) || 0) > (ms(a.meta.updated) || 0) ? b : a
1347
+ const older = newer === a ? b : a
1348
+ const norm = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim()
1349
+ const out = { meta: { ...older.meta, ...newer.meta, nextId: Math.max(a.meta.nextId || 0, b.meta.nextId || 0) }, items: [], segs: newer.segs }
1350
+ const byId = new Map()
1351
+ const take = (it) => {
1352
+ let cur = it.id ? byId.get(it.id) : null
1353
+ // Same number, different words: an edit if they still look alike, else two
1354
+ // different items that each copy numbered on its own (both stay; one is renumbered).
1355
+ if (cur && norm(cur.text) !== norm(it.text) && !sameItemWords(cur.text, it.text)) cur = null
1356
+ if (!cur) cur = out.items.find((x) => norm(x.text) === norm(it.text))
1357
+ if (!cur) { const c = { ...it }; out.items.push(c); if (c.id && !byId.has(c.id)) byId.set(c.id, c); else if (c.id) c.id = null; return }
1358
+ if ((ms(it.at) || 0) > (ms(cur.at) || 0)) Object.assign(cur, { text: it.text, done: it.done, at: it.at })
1359
+ }
1360
+ for (const it of newer.items) take(it)
1361
+ for (const it of older.items) take(it)
1362
+ // newer's layout, with its own item objects swapped for the merged ones
1363
+ out.segs = (newer.segs || []).map((s) => (s.item ? { item: out.items.find((x) => x.id === s.item.id && x.id) || out.items.find((x) => norm(x.text) === norm(s.item.text)) || s.item } : s))
1364
+ const seen = new Set()
1365
+ out.segs = out.segs.filter((s) => !s.item || (!seen.has(s.item) && seen.add(s.item)))
1366
+ return numberListItems(out)
1367
+ }
1368
+
1369
+ function sameItemWords(a, b) {
1370
+ const wa = new Set(String(a).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean))
1371
+ const wb = [...new Set(String(b).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean))]
1372
+ if (!wa.size || !wb.length) return false
1373
+ return wb.filter((w) => wa.has(w)).length / Math.max(wa.size, wb.length) > 0.5
1374
+ }
1375
+
1376
+ export function listProgress(list) {
1377
+ const total = list.items.length, done = list.items.filter((x) => x.done).length
1378
+ return { done, total, label: done + '/' + total }
1379
+ }
1380
+
1381
+ // /cp-todo [name] [--pick [N]] and /cp-done <n…|text> [in <list>]
1382
+ export function parseTodoArgs(args) {
1383
+ const s = String(args || '').trim()
1384
+ const m = s.match(/--pick(?:[=\s]+(\d+))?/)
1385
+ const rest = s.replace(/--pick(?:[=\s]+\d+)?/, '').trim()
1386
+ return { pick: m ? (m[1] ? Number(m[1]) : true) : null, name: rest || null }
1387
+ }
1388
+ export function parseDoneArgs(args) {
1389
+ const s = String(args || '').trim()
1390
+ const undo = /^(--undo|undo|not)\b/i.test(s)
1391
+ const body = s.replace(/^(--undo|undo|not)\s*/i, '')
1392
+ const inList = body.match(/\s+(?:in|from|on)\s+(\S+)\s*$/i)
1393
+ const main = inList ? body.slice(0, inList.index) : body
1394
+ const nums = main.split(/[\s,]+/).filter(Boolean)
1395
+ if (nums.length && nums.every((x) => /^#?\d+$/.test(x))) return { numbers: nums.map((x) => Number(x.replace('#', ''))), text: null, list: inList ? inList[1] : null, undo }
1396
+ return { numbers: [], text: main.trim() || null, list: inList ? inList[1] : null, undo }
1397
+ }