@leftoff/cli 0.9.18-beta.9 → 0.9.18
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.
- package/CHANGELOG.md +25 -2
- package/bin/leftoff-statusline.js +7 -7
- package/bin/leftoff.js +792 -677
- package/docs/SECURITY.md +1 -1
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +259 -17
- package/mod/hooks/register.js +18 -4
- package/package.json +1 -1
- package/ui/app.css +204 -5
- package/ui/app.js +621 -189
- package/ui/chip-fade.js +1 -1
- package/ui/cp-fold.js +42 -0
- package/ui/decisions.js +81 -21
- package/ui/digest-versions.js +57 -0
- package/ui/fs-side.js +29 -0
- package/ui/html-preview.html +12 -0
- package/ui/html-preview.js +136 -0
- package/ui/icons.js +1 -0
- package/ui/live-status.js +44 -3
- package/ui/live-view.js +36 -5
- package/ui/live-wake.js +47 -0
- package/ui/mcp-setup.js +26 -0
- package/ui/prs.js +28 -10
- package/ui/pull.js +30 -3
- package/ui/relay-client.js +2 -0
- package/ui/render-digest.js +98 -0
- package/ui/render-export.js +128 -0
- package/ui/semdiff.js +58 -27
- package/ui/session-title.js +88 -0
- package/ui/settings-nav.js +1 -1
- package/ui/sglyph.js +2 -2
- package/ui/slash-commands.js +89 -0
- package/ui/slash.js +82 -0
- package/ui/subagents-view.js +41 -0
- package/ui/summary-examples.js +1 -1
- package/ui/tabs.js +10 -3
- package/ui/viewport-fit.js +29 -3
- package/ui/walk-spots.js +35 -1
package/docs/SECURITY.md
CHANGED
|
@@ -94,7 +94,7 @@ A relayed request is replayed against the Mac's own board from inside the daemon
|
|
|
94
94
|
|
|
95
95
|
When your hosted board is open on a Mac that runs Leftoff, the page sends the same sealed requests straight to that Mac's daemon at `http://127.0.0.1:7429/v1/relay/direct` instead of to the relay. Nothing new is trusted: the daemon opens them with the same per-device key (and the same replay and time checks), runs them the same way (a remote device, the rules above), and seals the answer. The page only switches after the daemon says it's on your account and a sealed round trip through it works, so another Leftoff on that port (someone else's account) can't serve your board: it can't open the request or forge the answer. The door is loopback only, never through a proxy, off when Use from anywhere is off, and answers CORS for exactly one origin, `https://<your name>.leftoff.dev` (with Chrome's Private Network Access header); any other page gets a 403 it can't read. If the direct path fails, the request goes through the relay once more and the board stays on the relay until a later check finds the direct path again.
|
|
96
96
|
|
|
97
|
-
Since 0.9.7 the page also tries `https://leftoff.localhost/v1/relay/direct` (Leftoff's own Caddy from `leftoff setup`, bound to loopback; it rewrites Host, drops X-Forwarded-*, and passes the page's Origin through), since Safari won't let an https page call plain `http://127.0.0.1`. A request that came in through this door came from a browser on this Mac, from your own board's origin, sealed with a paired key, so it may also do the one thing only this Mac's own browser may do: **Restore** (`POST /v1/restore`, which opens Ghostty tabs here). The daemon marks such requests with a second in-memory secret that only the door sets; a sealed request can't carry it, and every other endpoint treats a direct request exactly like the relay (a paired remote device). Through the relay, from a phone or another Mac, Restore stays off.
|
|
97
|
+
Since 0.9.7 the page also tries `https://leftoff.localhost/v1/relay/direct` (Leftoff's own Caddy from `leftoff setup`, bound to loopback, or since 0.9.18 another app's Caddy on port 443 that imports Leftoff's site file, which turns away any client not on loopback by its address; it rewrites Host, drops X-Forwarded-*, and passes the page's Origin through), since Safari won't let an https page call plain `http://127.0.0.1`. A request that came in through this door came from a browser on this Mac, from your own board's origin, sealed with a paired key, so it may also do the one thing only this Mac's own browser may do: **Restore** (`POST /v1/restore`, which opens Ghostty tabs here). The daemon marks such requests with a second in-memory secret that only the door sets; a sealed request can't carry it, and every other endpoint treats a direct request exactly like the relay (a paired remote device). Through the relay, from a phone or another Mac, Restore stays off.
|
|
98
98
|
|
|
99
99
|
### Live link (0.9.8 beta)
|
|
100
100
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.18
|
|
3
|
+
"version": "0.9.18",
|
|
4
4
|
"description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "One Silly App"
|
package/mod/hooks/core.js
CHANGED
|
@@ -508,6 +508,10 @@ export function renderCheckpointMd(cp) {
|
|
|
508
508
|
const bullet = (x) => '- ' + oneLine(x).replace(/^\*+/, '')
|
|
509
509
|
// 2026-11-03 decision memory: the decisions this checkpoint saw, first of the
|
|
510
510
|
// sections, so an older reader (no section open yet) skips the heading and its lines.
|
|
511
|
+
// 2026-11-06: what the session's commands show got done (workFacts), first of all, so the
|
|
512
|
+
// digest can say what shipped in past tense; an older reader skips the heading and its lines
|
|
513
|
+
const work = (Array.isArray(cp.work) ? cp.work : []).map(renderWorkLine).filter(Boolean)
|
|
514
|
+
if (work.length) { lines.push('', '### ' + WORK_TITLE); for (const w of work) lines.push('- ' + w) }
|
|
511
515
|
const decided = cleanDecided(cp.decided)
|
|
512
516
|
if (decided.length) { lines.push('', '### ' + DECIDED_TITLE); for (const d of decided) lines.push(renderDecidedLine(d)) }
|
|
513
517
|
for (const k of SECTION_ORDER) {
|
|
@@ -566,7 +570,15 @@ export function parseCheckpointMd(text) {
|
|
|
566
570
|
if (cp.format === 'markdown') for (const f of ['doing', 'stoppedAt', 'next']) cp[f] = cp[f].replace(/\n{3,}/g, '\n\n').trim()
|
|
567
571
|
const sec = []
|
|
568
572
|
for (; k < lines.length; k++) { if (/^Resume: `/.test(lines[k])) break; sec.push(lines[k]) }
|
|
573
|
+
// 2026-11-06: "### Got done" lines are facts in a fixed shape (renderWorkLine); read them here, not as model prose
|
|
574
|
+
const work = []
|
|
575
|
+
let inWork = false
|
|
576
|
+
for (const l of sec) {
|
|
577
|
+
if (/^###\s/.test(l)) { inWork = l.replace(/^###\s+/, '').trim() === WORK_TITLE; continue }
|
|
578
|
+
if (inWork) { const f = parseWorkLine(l); if (f) work.push(f) }
|
|
579
|
+
}
|
|
569
580
|
Object.assign(cp, parseSections(sec))
|
|
581
|
+
if (work.length) cp.work = work
|
|
570
582
|
return cp
|
|
571
583
|
}
|
|
572
584
|
|
|
@@ -904,10 +916,11 @@ export function computeStatus(s, now, cfg = DEFAULT_CONFIG) {
|
|
|
904
916
|
// fire), so a turn that has been silent for twice the dormancy window is
|
|
905
917
|
// treated as over. The mod sees turn.complete for aborted turns too.
|
|
906
918
|
// 0.8.0: a Codex rollout shows no end for a turn Codex was killed in, either.
|
|
907
|
-
|
|
919
|
+
// 2026-11-06: a subagent working is the turn going on, however quiet the session's own hooks are
|
|
920
|
+
if ((s.capture === 'hooks' || s.capture === 'codex') && idle >= 2 * dormantAfter && !(s.subagents && s.subagents.n > 0)) return 'dormant'
|
|
908
921
|
return 'active'
|
|
909
922
|
}
|
|
910
|
-
if (s.backgroundTasks > 0) return 'background'
|
|
923
|
+
if (s.backgroundTasks > 0 || (s.subagents && s.subagents.n > 0)) return 'background' // 2026-11-06: subagents working (the daemon reads them, src/subagents.js), a turn's end missed or not
|
|
911
924
|
return idle >= dormantAfter ? 'dormant' : 'waiting'
|
|
912
925
|
}
|
|
913
926
|
|
|
@@ -1335,6 +1348,189 @@ export function transcriptSlice(messages, { maxInputChars = 24000, cwd } = {}) {
|
|
|
1335
1348
|
return blocks.reverse().join('\n')
|
|
1336
1349
|
}
|
|
1337
1350
|
|
|
1351
|
+
// ---------------------------------------------------------------- work facts (2026-11-06)
|
|
1352
|
+
//
|
|
1353
|
+
// What a session got done, read from the commands it ran and what they printed,
|
|
1354
|
+
// never from a model: commits ("[branch abc1234] subject"), PRs opened (gh pr
|
|
1355
|
+
// create's URL) and merged (gh pr merge, or GitHub's MCP merge tool), branches
|
|
1356
|
+
// pushed, and the last test run with its counts. A checkpoint keeps them under
|
|
1357
|
+
// "### Got done", one line each, so the digest can say "Merged PR #53: …" in
|
|
1358
|
+
// past tense instead of the last status line ("PR 53 was ready to merge").
|
|
1359
|
+
export const WORK_TITLE = 'Got done'
|
|
1360
|
+
export const WORK_MAX = { prs: 6, commits: 10, pushes: 3 }
|
|
1361
|
+
|
|
1362
|
+
const quoted = (s) => s == null ? '' : String(s)
|
|
1363
|
+
function argOf(cmd, names) {
|
|
1364
|
+
for (const n of names) {
|
|
1365
|
+
const re = new RegExp('(?:^|\\s)' + n.replace(/-/g, '\\-') + '(?:=|\\s+)(?:"((?:[^"\\\\]|\\\\.)*)"|\'([^\']*)\'|(\\S+))')
|
|
1366
|
+
const m = String(cmd).match(re)
|
|
1367
|
+
if (m) return quoted(m[1] != null ? m[1].replace(/\\(.)/g, '$1') : m[2] != null ? m[2] : m[3])
|
|
1368
|
+
}
|
|
1369
|
+
return ''
|
|
1370
|
+
}
|
|
1371
|
+
const firstLineOf = (t) => String(t || '').split('\n').map((x) => x.trim()).find(Boolean) || ''
|
|
1372
|
+
const TEST_CMD = /(?:^|[\s;&|(])((?:(?:env\s+(?:-u\s+\S+\s+)*)?(?:timeout\s+(?:-k\s+\S+\s+)?\S+\s+)?)?(?:(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?test(?::[\w:.-]+)?|node\s+(?:--?[\w-]+(?:[= ]\S+)?\s+)*--test\b|npx\s+(?:vitest|jest|mocha)|vitest|jest|pytest|python3?\s+-m\s+pytest|go\s+test|cargo\s+test|mocha|rspec|swift\s+test|make\s+test|deno\s+test))/
|
|
1373
|
+
function testCounts(text) {
|
|
1374
|
+
const t = String(text || '')
|
|
1375
|
+
let m, passed = null, failed = null
|
|
1376
|
+
if ((m = t.match(/test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed/))) return { passed: +m[1], failed: +m[2] }
|
|
1377
|
+
const np = [...t.matchAll(/^\s*(?:#|ℹ)\s*pass\s+(\d+)\s*$/gm)].pop(), nf = [...t.matchAll(/^\s*(?:#|ℹ)\s*fail\s+(\d+)\s*$/gm)].pop()
|
|
1378
|
+
if (np || nf) return { passed: np ? +np[1] : 0, failed: nf ? +nf[1] : 0 }
|
|
1379
|
+
if ((m = t.match(/^\s*Tests:?\s+(.*)$/m)) && /\d+ (?:passed|failed)/.test(m[1])) {
|
|
1380
|
+
const p = m[1].match(/(\d+) passed/), f = m[1].match(/(\d+) failed/)
|
|
1381
|
+
return { passed: p ? +p[1] : 0, failed: f ? +f[1] : 0 }
|
|
1382
|
+
}
|
|
1383
|
+
const lines = t.split('\n').filter((l) => /\d+ (?:passed|failed)\b/.test(l))
|
|
1384
|
+
if (lines.length) {
|
|
1385
|
+
const l = lines[lines.length - 1], p = l.match(/(\d+) passed/), f = l.match(/(\d+) failed/)
|
|
1386
|
+
passed = p ? +p[1] : 0; failed = f ? +f[1] : 0
|
|
1387
|
+
return { passed, failed }
|
|
1388
|
+
}
|
|
1389
|
+
return null
|
|
1390
|
+
}
|
|
1391
|
+
const PR_URL = /https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/
|
|
1392
|
+
|
|
1393
|
+
export function workFacts(messages) {
|
|
1394
|
+
const prs = new Map() // number -> { kind: 'pr', number, title, state, repo }
|
|
1395
|
+
const commits = [], pushes = []
|
|
1396
|
+
let tests = null
|
|
1397
|
+
const results = new Map()
|
|
1398
|
+
for (const m of messages || []) for (const r of m.toolResults || []) if (r && r.tool_use_id) results.set(r.tool_use_id, r)
|
|
1399
|
+
const pr = (number, patch) => {
|
|
1400
|
+
const n = Number(number)
|
|
1401
|
+
if (!n) return
|
|
1402
|
+
const cur = prs.get(n) || { kind: 'pr', number: n, title: '', state: 'opened', repo: null }
|
|
1403
|
+
// merged is final: a later "opened" (a re-run of create) doesn't take it back
|
|
1404
|
+
const state = cur.state === 'merged' ? 'merged' : (patch.state || cur.state)
|
|
1405
|
+
prs.delete(n) // newest last
|
|
1406
|
+
prs.set(n, { ...cur, ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v)), state })
|
|
1407
|
+
}
|
|
1408
|
+
for (const m of messages || []) {
|
|
1409
|
+
if (m.role !== 'assistant') continue
|
|
1410
|
+
for (const t of m.toolUses || []) {
|
|
1411
|
+
const name = String(t.tool || t.name || '')
|
|
1412
|
+
const res = (t.tool_use_id && results.get(t.tool_use_id)) || null
|
|
1413
|
+
const text = String(t.text != null ? t.text : (res && res.text) || '')
|
|
1414
|
+
const isError = !!(t.isError || (res && res.isError))
|
|
1415
|
+
const input = t.input || {}
|
|
1416
|
+
// GitHub's MCP tools (mcp__github__create_pull_request / merge_pull_request)
|
|
1417
|
+
if (/(?:^|__)create_pull_request$/.test(name)) {
|
|
1418
|
+
if (isError) continue
|
|
1419
|
+
const u = text.match(PR_URL), n = (u && u[2]) || ((text.match(/"number"\s*:\s*(\d+)/) || [])[1])
|
|
1420
|
+
pr(n, { title: clip(String(input.title || ''), 120), state: 'opened', repo: u ? u[1] : (input.owner && input.repo ? input.owner + '/' + input.repo : null) })
|
|
1421
|
+
continue
|
|
1422
|
+
}
|
|
1423
|
+
if (/(?:^|__)merge_pull_request$/.test(name)) {
|
|
1424
|
+
if (isError || /not mergeable|failed|error/i.test(firstLineOf(text))) continue
|
|
1425
|
+
pr(input.pull_number || input.pullNumber || input.number, { state: 'merged', title: clip(String(input.commit_title || ''), 120).replace(/\s*\(#\d+\)$/, ''), repo: input.owner && input.repo ? input.owner + '/' + input.repo : null })
|
|
1426
|
+
continue
|
|
1427
|
+
}
|
|
1428
|
+
if (name !== 'Bash' || !input.command) continue
|
|
1429
|
+
const cmd = String(input.command)
|
|
1430
|
+
if (/\bgit\b[^\n|;&]*\bcommit\b/.test(cmd) && !/--dry-run/.test(cmd)) {
|
|
1431
|
+
for (const c of text.matchAll(/^\[([^\]\s]+)(?: \([^)]*\))? ([0-9a-f]{7,40})\] (.+)$/gm)) {
|
|
1432
|
+
const subject = clip(c[3].trim(), 120)
|
|
1433
|
+
const i = commits.findIndex((x) => x.subject === subject) // an --amend: the new sha, one line
|
|
1434
|
+
if (i >= 0) commits.splice(i, 1)
|
|
1435
|
+
commits.push({ kind: 'commit', sha: c[2].slice(0, 7), subject, branch: c[1] })
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
if (/\bgh\s+pr\s+create\b/.test(cmd) && !isError) {
|
|
1439
|
+
const u = text.match(PR_URL)
|
|
1440
|
+
if (u) pr(u[2], { title: clip(argOf(cmd, ['--title', '-t']), 120), state: 'opened', repo: u[1], branch: argOf(cmd, ['--head', '-H']).replace(/^[\w.-]+:/, '') || pushes[pushes.length - 1] || (commits.length ? commits[commits.length - 1].branch : '') })
|
|
1441
|
+
}
|
|
1442
|
+
if (/\bgh\s+pr\s+merge\b/.test(cmd) && !isError) {
|
|
1443
|
+
const n = (cmd.match(/\bgh\s+pr\s+merge\s+(?:[^\s-]\S*\/pull\/)?#?(\d+)\b/) || [])[1] || (text.match(/pull request (?:[\w.-]+\/[\w.-]+)?#(\d+)/i) || [])[1] || ((text.match(PR_URL) || [])[2])
|
|
1444
|
+
const title = (text.match(/pull request (?:[\w.-]+\/[\w.-]+)?#\d+ \((.+)\)\s*$/im) || [])[1] || ''
|
|
1445
|
+
if (/automatically merged|auto-?merge|merge queue|will be merged/i.test(text) || /--auto\b/.test(cmd)) pr(n, { state: 'auto', title: clip(title, 120) })
|
|
1446
|
+
else if (!/not mergeable|failed|error:/i.test(text)) pr(n, { state: 'merged', title: clip(title, 120) })
|
|
1447
|
+
}
|
|
1448
|
+
if (/\bgit\s+push\b/.test(cmd) && !isError) {
|
|
1449
|
+
const b = [...text.matchAll(/(?:\[new branch\]|\.\.|\+)\s*\S*\s+(\S+)\s+->\s+(\S+)/g)].map((x) => x[2]).pop() || [...text.matchAll(/\s(\S+)\s+->\s+(\S+)/g)].map((x) => x[2]).pop()
|
|
1450
|
+
if (b) { const i = pushes.indexOf(b); if (i >= 0) pushes.splice(i, 1); pushes.push(b) }
|
|
1451
|
+
}
|
|
1452
|
+
const tc = cmd.match(TEST_CMD)
|
|
1453
|
+
if (tc) {
|
|
1454
|
+
const counts = testCounts(text)
|
|
1455
|
+
if (counts || isError) tests = { kind: 'tests', ok: !isError && (!counts || counts.failed === 0), passed: counts ? counts.passed : null, failed: counts ? counts.failed : null, cmd: clip(tc[1].replace(/^env\s+(?:-u\s+\S+\s+)*/, '').replace(/^timeout\s+(?:-k\s+\S+\s+)?\S+\s+/, '').replace(/\s--?(?:import|require|loader|r)[= ]\S+/g, '').trim(), 60) }
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
const out = []
|
|
1460
|
+
const prList = [...prs.values()].slice(-WORK_MAX.prs)
|
|
1461
|
+
out.push(...prList.filter((p) => p.state === 'merged'), ...prList.filter((p) => p.state !== 'merged'))
|
|
1462
|
+
out.push(...commits.slice(-WORK_MAX.commits))
|
|
1463
|
+
out.push(...pushes.slice(-WORK_MAX.pushes).map((branch) => ({ kind: 'push', branch })))
|
|
1464
|
+
if (tests) out.push(tests)
|
|
1465
|
+
return out
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
const oneL = (s) => String(s == null ? '' : s).replace(/\s*\n\s*/g, ' ').trim()
|
|
1469
|
+
// One fact, one line; parseWorkLine reads it back.
|
|
1470
|
+
export function renderWorkLine(f) {
|
|
1471
|
+
if (!f) return ''
|
|
1472
|
+
if (f.kind === 'pr') {
|
|
1473
|
+
const head = (f.state === 'merged' ? 'Merged' : f.state === 'auto' ? 'Set to merge' : 'Opened') + ' PR #' + f.number + (f.repo ? ' (' + f.repo + ')' : '') + (f.branch ? ' from ' + f.branch : '')
|
|
1474
|
+
return head + (f.title ? ': ' + oneL(f.title) : '')
|
|
1475
|
+
}
|
|
1476
|
+
if (f.kind === 'commit') return 'Committed ' + f.sha + (f.branch ? ' on ' + f.branch : '') + ': ' + oneL(f.subject)
|
|
1477
|
+
if (f.kind === 'push') return 'Pushed ' + f.branch
|
|
1478
|
+
if (f.kind === 'tests') {
|
|
1479
|
+
const n = f.passed != null ? f.passed + ' passed' + (f.failed ? ', ' + f.failed + ' failed' : '') : ''
|
|
1480
|
+
return 'Tests ' + (f.ok ? 'passed' : 'failed') + (n ? ': ' + n : '') + (f.cmd ? ' (' + oneL(f.cmd).replace(/[()]/g, '') + ')' : '')
|
|
1481
|
+
}
|
|
1482
|
+
return ''
|
|
1483
|
+
}
|
|
1484
|
+
export function parseWorkLine(line) {
|
|
1485
|
+
const t = String(line || '').replace(/^\s*[-*•]\s*/, '').trim()
|
|
1486
|
+
let m
|
|
1487
|
+
if ((m = t.match(/^(Merged|Opened|Set to merge) PR #(\d+)(?: \(([\w.-]+\/[\w.-]+)\))?(?: from (\S+?))?(?::\s*(.*))?$/))) return { kind: 'pr', number: +m[2], state: m[1] === 'Merged' ? 'merged' : m[1] === 'Opened' ? 'opened' : 'auto', repo: m[3] || null, ...(m[4] ? { branch: m[4] } : {}), title: (m[5] || '').trim() }
|
|
1488
|
+
if ((m = t.match(/^Committed ([0-9a-f]{7,40})(?: on (\S+))?:\s*(.*)$/))) return { kind: 'commit', sha: m[1], branch: m[2] || null, subject: m[3].trim() }
|
|
1489
|
+
if ((m = t.match(/^Pushed (\S+)$/))) return { kind: 'push', branch: m[1] }
|
|
1490
|
+
if ((m = t.match(/^Tests (passed|failed)(?::\s*(\d+) passed(?:, (\d+) failed)?)?(?: \((.*)\))?$/))) return { kind: 'tests', ok: m[1] === 'passed', passed: m[2] != null ? +m[2] : null, failed: m[2] != null ? +(m[3] || 0) : null, cmd: m[4] || '' }
|
|
1491
|
+
return null
|
|
1492
|
+
}
|
|
1493
|
+
// "PR 53 is ready to merge" / "Merge PR 53" about a PR that has merged: a status line gone stale.
|
|
1494
|
+
export const STATUSY_PR = /\bPR\s*#?(\d+)\b[^.;]{0,60}?\b(?:ready to merge|ready for review|ready to land|awaiting (?:review|merge|approval)|waiting (?:on|for) (?:review|merge|approval|checks|CI)|open(?:ed)?(?: and)? (?:for|awaiting)|in review|pending (?:review|merge))/i
|
|
1495
|
+
export const STALE_NEXT_PR = /\b(?:merge|land|ship|review|approve|squash)\b[^.;]{0,40}?\bPR\s*#?(\d+)/i
|
|
1496
|
+
// A checkpoint whose Stopped at / Next calls a PR its own facts show merged ready or next to merge:
|
|
1497
|
+
// Stopped at becomes what the facts say, Next is dropped (the digest's next step comes from elsewhere).
|
|
1498
|
+
// -> { stoppedAt, next, corrected: [fields] }; nothing changed when nothing is contradicted.
|
|
1499
|
+
export function reconcileWithWork(cp, work) {
|
|
1500
|
+
const merged = new Map((work || []).filter((f) => f && f.kind === 'pr' && f.state === 'merged').map((f) => [f.number, f]))
|
|
1501
|
+
const out = { stoppedAt: cp.stoppedAt, next: cp.next, corrected: [] }
|
|
1502
|
+
if (!merged.size) return out
|
|
1503
|
+
const one = (t) => String(t || '').replace(/\s+/g, ' ')
|
|
1504
|
+
const m = one(cp.stoppedAt).match(STATUSY_PR)
|
|
1505
|
+
if (m && merged.has(Number(m[1]))) {
|
|
1506
|
+
const pr = merged.get(Number(m[1]))
|
|
1507
|
+
const tests = (work || []).filter((f) => f.kind === 'tests').pop()
|
|
1508
|
+
out.stoppedAt = 'PR #' + pr.number + (pr.title ? ' (' + clip(pr.title, 80) + ')' : '') + ' is merged' + (tests ? '; tests ' + (tests.ok ? 'pass' : 'fail') + (tests.passed != null ? ' (' + (tests.ok ? tests.passed + ' passed' : tests.failed + ' failed') + ')' : '') : '') + '.'
|
|
1509
|
+
out.corrected.push('stoppedAt')
|
|
1510
|
+
}
|
|
1511
|
+
const n = one(cp.next).match(STALE_NEXT_PR)
|
|
1512
|
+
if (n && merged.has(Number(n[1]))) { out.next = ''; out.corrected.push('next') }
|
|
1513
|
+
return out
|
|
1514
|
+
}
|
|
1515
|
+
// A work list from anywhere (the mod's POST, a file) made safe: only what renders and reads back.
|
|
1516
|
+
export function cleanWork(list) {
|
|
1517
|
+
const out = []
|
|
1518
|
+
for (const f of Array.isArray(list) ? list : []) {
|
|
1519
|
+
const w = typeof f === 'string' ? parseWorkLine(f) : parseWorkLine(renderWorkLine(f))
|
|
1520
|
+
if (w) out.push(w)
|
|
1521
|
+
}
|
|
1522
|
+
return out.slice(0, WORK_MAX.prs + WORK_MAX.commits + WORK_MAX.pushes + 1)
|
|
1523
|
+
}
|
|
1524
|
+
// The same fact in two checkpoints is one fact (a commit by its subject, since an amend changes the sha).
|
|
1525
|
+
export function workKey(f) {
|
|
1526
|
+
if (!f) return ''
|
|
1527
|
+
if (f.kind === 'pr') return 'pr:#' + f.number
|
|
1528
|
+
if (f.kind === 'commit') return 'commit:' + String(f.subject || '').toLowerCase()
|
|
1529
|
+
if (f.kind === 'push') return 'push:' + f.branch
|
|
1530
|
+
if (f.kind === 'tests') return 'tests'
|
|
1531
|
+
return ''
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1338
1534
|
// ---------------------------------------------------------------- checkpoint length (v0.4.6)
|
|
1339
1535
|
//
|
|
1340
1536
|
// config summary.length picks how much a checkpoint says. Every length keeps the
|
|
@@ -1387,17 +1583,18 @@ const CHECKPOINT_BASE = [
|
|
|
1387
1583
|
'You write checkpoints for a developer juggling many coding-agent sessions, read when they come back after half an hour, a day or a weekend. Given the recent transcript of ONE session, start with exactly these five lines:',
|
|
1388
1584
|
'Goal: <the outcome the developer is after, from what they asked for, in a few plain words; not the latest step. Keep the previous goal unless they changed course or it is done and they moved on>',
|
|
1389
1585
|
'Progress: <one of Just started, Underway, Mostly done, Done, Stuck> - <what is left between here and the goal, a few words>',
|
|
1390
|
-
'Doing: <what the session was working on, one sentence, concrete nouns: files, features, errors>',
|
|
1391
|
-
'Stopped at: <the exact state when it went quiet: what passed, what failed, what is half-done; say what was actually run or checked, and what is only assumed>',
|
|
1586
|
+
'Doing: <what the session got done or was working on, one sentence, concrete nouns: files, features, errors; past tense for what is finished, e.g. "Merged PR #53, which keeps the reply box above the keyboard">',
|
|
1587
|
+
'Stopped at: <the exact state when it went quiet, as outcomes: what passed, what failed, what is half-done, what is merged and what is not; say what was actually run or checked, and what is only assumed>',
|
|
1392
1588
|
'Next: <the single next action a human or the agent should take>',
|
|
1393
1589
|
'If the session is blocked on the human (a question, a permission, a decision), start Next with "YOU:".',
|
|
1590
|
+
'Say outcomes, not statuses: "Merged PR #53" or "Opened PR #53, not merged yet", never "PR 53 was ready to merge". The "Verified from the commands\' output" line is the record of commits, PRs, pushes and tests: never contradict it, and never claim a merge, commit or passing test it does not show.',
|
|
1394
1591
|
'Describe only what the transcript shows; the lines above it (repo, branch, title, what was asked, previous checkpoint) are background and may be out of date.',
|
|
1395
1592
|
]
|
|
1396
1593
|
// 2026-11-03 decision memory: only when the transcript shows one, quoted, never invented.
|
|
1397
1594
|
const DECIDED_SECTION = [
|
|
1398
1595
|
'Only if the transcript shows a decision (a choice between real alternatives that the developer made or approved, a plan they approved, a correction they gave such as "no, do X instead", or a trade-off the agent chose and said so), add after those lines a "Decided:" heading and one "- " line per decision, at most 4:',
|
|
1399
1596
|
'- <what was decided, in the words the transcript uses> · why: <the reason given, one line> · over: <alternative>; <alternative> · from: <asked | plan | correction | agent>',
|
|
1400
|
-
'A task done, a file edited or a routine step is not a decision. Never invent a choice or a reason the transcript does not show. When unsure, or when there is none, leave the Decided heading out entirely. Skip decisions listed as already recorded.',
|
|
1597
|
+
'A task done, a file edited or a routine step is not a decision, and neither is a one-off choice about this task right now (who does a step, whether to commit, push or deploy now). Never invent a choice or a reason the transcript does not show. When unsure, or when there is none, leave the Decided heading out entirely. Skip decisions listed as already recorded.',
|
|
1401
1598
|
]
|
|
1402
1599
|
const INFORMATIVE_SECTIONS = [
|
|
1403
1600
|
'Then these sections, each a heading line followed by "- " bullets (write "- none" when there is nothing; never invent):',
|
|
@@ -1477,12 +1674,15 @@ export function buildCheckpointPrompt({ session, previous, messages, cfg = DEFAU
|
|
|
1477
1674
|
const red = (t) => redact(String(t || ''), (cfg.redact && cfg.redact.patterns) || [])
|
|
1478
1675
|
const found = decisionsFromMessages(messages).map((d) => ({ ...d, text: red(d.text), why: red(d.why), alternatives: d.alternatives.map(red) }))
|
|
1479
1676
|
if (found.length) head.push('Decisions already recorded from the session\'s questions and plans (don\'t repeat them): ' + found.map((d) => '“' + clip(d.text, 120) + '”').join('; '))
|
|
1677
|
+
// 2026-11-06: what the commands' own output shows got done, so the model says "Merged PR #53" and not "PR 53 was ready to merge"
|
|
1678
|
+
const work = workFacts(messages).map((f) => (f.kind === 'pr' || f.kind === 'commit' ? { ...f, title: f.title && red(f.title), subject: f.subject && red(f.subject) } : f)).map((f) => Object.fromEntries(Object.entries(f).filter(([, v]) => v !== undefined)))
|
|
1679
|
+
head.push('Verified from the commands\' output (git, gh, the test runner) this session: ' + (work.length ? work.map(renderWorkLine).join('; ') : 'no commit, PR, push or test run'))
|
|
1480
1680
|
if (session.needsInput) head.push('State: the session is waiting on the developer (' + (session.lastNotification || 'a prompt') + ').')
|
|
1481
1681
|
if (wantTitle) head.push('Also write the line: Title: <max 6 words>.')
|
|
1482
1682
|
const slice = transcriptSlice(messages, { maxInputChars, cwd })
|
|
1483
1683
|
const patterns = (cfg.redact && cfg.redact.patterns) || []
|
|
1484
1684
|
const prompt = redact(head.join('\n') + '\nTranscript (oldest→newest, trimmed):\n' + (slice || '(empty)'), patterns)
|
|
1485
|
-
return { system: CHECKPOINT_SYSTEMS[len], prompt, filesTouched: files, inputChars: prompt.length, length: len, maxTokens: SUMMARY_LENGTHS[len].maxTokens, found }
|
|
1685
|
+
return { system: CHECKPOINT_SYSTEMS[len], prompt, filesTouched: files, inputChars: prompt.length, length: len, maxTokens: SUMMARY_LENGTHS[len].maxTokens, found, work }
|
|
1486
1686
|
}
|
|
1487
1687
|
|
|
1488
1688
|
// ---------------------------------------------------------------- decisions (2026-11-03)
|
|
@@ -1577,12 +1777,23 @@ function planHeadline(plan) {
|
|
|
1577
1777
|
return h.replace(/^#{1,6}\s+/, '').replace(/^(?:plan|implementation plan)\s*[:\-–—]\s*/i, '').replace(/\*\*/g, '').trim()
|
|
1578
1778
|
}
|
|
1579
1779
|
|
|
1580
|
-
//
|
|
1581
|
-
//
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1780
|
+
// 2026-11-05: a question about one task right now isn't a decision for the repo: who does it ("You update
|
|
1781
|
+
// it" / "I'll handle it"), or whether to go ahead with a commit, push, deploy and the like. Never recorded.
|
|
1782
|
+
const ACTOR_OPT = /^(?:you|i|i'll|i will|i'd|me|let me|we|we'll|leave it|someone)\b/i
|
|
1783
|
+
const NOW_Q = /\b(?:go ahead|proceed|right now|commit|push|merge|deploy|ship|publish|release|restart|open (?:a|the) pr|create (?:a|the) pr|who (?:should|will|does))\b/i
|
|
1784
|
+
const YESNO_OPT = /^(?:yes|no|y|n|ok|okay|sure|go ahead|do it|proceed|not now|later|skip|wait|stop|hold off|cancel|continue)\b/i
|
|
1785
|
+
export function isOneOffQuestion(q) {
|
|
1786
|
+
const labels = (Array.isArray(q && q.options) ? q.options : []).map((o) => String((o && o.label) || o || '').replace(/\s*\(Recommended\)\s*/i, '').trim()).filter(Boolean)
|
|
1787
|
+
if (labels.length < 2) return false
|
|
1788
|
+
if (labels.every((l) => ACTOR_OPT.test(l))) return true
|
|
1789
|
+
return NOW_Q.test(String((q && q.question) || '')) && labels.every((l) => YESNO_OPT.test(l))
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// The transcript's own decisions, oldest first, and the answers it later changed:
|
|
1793
|
+
// a question asked again (same header, else the same question) and answered
|
|
1794
|
+
// differently keeps only the last answer; the earlier ones are `reversed` (texts).
|
|
1795
|
+
function readStated(messages) {
|
|
1796
|
+
const out = [], at = new Map(), replaced = new Set()
|
|
1586
1797
|
for (const m of messages || []) {
|
|
1587
1798
|
if (!m || m.role !== 'assistant') continue
|
|
1588
1799
|
for (const u of m.toolUses || []) {
|
|
@@ -1593,17 +1804,22 @@ export function decisionsFromMessages(messages) {
|
|
|
1593
1804
|
const ans = questionAnswers(u.text, qs.map((q) => String(q.question)))
|
|
1594
1805
|
for (const q of qs) {
|
|
1595
1806
|
const a = ans[q.question]
|
|
1596
|
-
if (!a) continue
|
|
1807
|
+
if (!a || isOneOffQuestion(q)) continue
|
|
1597
1808
|
const labels = q.options.map((o) => String((o && o.label) || o || '').trim()).filter(Boolean)
|
|
1598
1809
|
const picked = labels.filter((l) => a === l || a.split(/\s*,\s*/).includes(l))
|
|
1599
1810
|
const chosen = picked.length ? picked.join(', ') : a
|
|
1600
1811
|
const opt = q.options.find((o) => o && o.label === picked[0])
|
|
1601
|
-
|
|
1812
|
+
const d = {
|
|
1602
1813
|
text: clip(String(q.header ? q.header + ': ' : '') + chosen.replace(/\s*\(Recommended\)\s*/i, ''), 240),
|
|
1603
1814
|
why: opt && opt.description ? opt.description : '',
|
|
1604
1815
|
alternatives: labels.filter((l) => !picked.includes(l)).map((l) => l.replace(/\s*\(Recommended\)\s*/i, '')),
|
|
1605
1816
|
source: 'asked',
|
|
1606
|
-
}
|
|
1817
|
+
}
|
|
1818
|
+
const topic = 'q:' + decisionNorm(q.header || q.question)
|
|
1819
|
+
const was = at.get(topic)
|
|
1820
|
+
if (was != null && decisionNorm(out[was].text) !== decisionNorm(d.text)) { replaced.add(out[was].text); out[was] = null }
|
|
1821
|
+
if (was != null && out[was]) continue // the same answer again
|
|
1822
|
+
at.set(topic, out.length); out.push(d)
|
|
1607
1823
|
}
|
|
1608
1824
|
}
|
|
1609
1825
|
if (name === 'ExitPlanMode' && u.text && PLAN_OK.test(u.text)) {
|
|
@@ -1612,7 +1828,29 @@ export function decisionsFromMessages(messages) {
|
|
|
1612
1828
|
}
|
|
1613
1829
|
}
|
|
1614
1830
|
}
|
|
1615
|
-
|
|
1831
|
+
const found = out.filter(Boolean)
|
|
1832
|
+
const kept = new Set(found.map((d) => decisionNorm(d.text)))
|
|
1833
|
+
return { found, reversed: [...replaced].filter((t) => !kept.has(decisionNorm(t))) }
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
// The decisions a transcript states outright, with no model: a question you
|
|
1837
|
+
// answered from its options (AskUserQuestion, the board's Needs you), its other
|
|
1838
|
+
// options as the alternatives, only its last answer; a plan you approved
|
|
1839
|
+
// (ExitPlanMode). Messages are src/transcript.js's shape, which is also
|
|
1840
|
+
// $.session.messages()'s.
|
|
1841
|
+
export function decisionsFromMessages(messages) {
|
|
1842
|
+
return cleanDecided(readStated(messages).found)
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// 2026-11-05: what the log settles on its own (src/decisions.js settleStated): the stated decisions,
|
|
1846
|
+
// redacted as checkpoints save them, and the texts of answers changed later in the session.
|
|
1847
|
+
export function statedDecisions(messages, patterns = []) {
|
|
1848
|
+
const red = (t) => redact(String(t || ''), patterns || [])
|
|
1849
|
+
const { found, reversed } = readStated(messages)
|
|
1850
|
+
return {
|
|
1851
|
+
found: cleanDecided(found).map((d) => ({ ...d, text: red(d.text), why: red(d.why), alternatives: d.alternatives.map(red) })),
|
|
1852
|
+
reversed: reversed.map((t) => red(clip(oneLine(t), 240))),
|
|
1853
|
+
}
|
|
1616
1854
|
}
|
|
1617
1855
|
|
|
1618
1856
|
// The checkpoint's decisions: what the transcript states outright first, then the model's.
|
|
@@ -1633,6 +1871,9 @@ const SECTION_KEYS = {
|
|
|
1633
1871
|
'didn\'t work': 'didntWork', 'didnt work': 'didntWork', 'did not work': 'didntWork', 'what didn\'t work': 'didntWork', 'what didnt work': 'didntWork', 'tried': 'didntWork',
|
|
1634
1872
|
'recap': 'recap',
|
|
1635
1873
|
'next steps': 'nextSteps', 'next 3 steps': 'nextSteps',
|
|
1874
|
+
// 2026-11-06: "Got done" is written in code from the commands' output (workFacts); a model
|
|
1875
|
+
// that writes the heading itself is not a source for it, so its lines are dropped
|
|
1876
|
+
'got done': 'gotDone',
|
|
1636
1877
|
'decided': 'decided', // 2026-11-03 decision memory
|
|
1637
1878
|
}
|
|
1638
1879
|
export const SECTION_TITLES = { decisions: 'Approach notes', questions: 'Open questions', files: 'Files touched', didntWork: 'What didn\'t work', recap: 'Recap', nextSteps: 'Next steps' }
|
|
@@ -1662,6 +1903,7 @@ function parseSections(lines) {
|
|
|
1662
1903
|
const push = (k, v) => {
|
|
1663
1904
|
v = String(v || '').replace(/\*\*/g, '').trim()
|
|
1664
1905
|
if (!v || NONE_RE.test(v)) return
|
|
1906
|
+
if (k === 'gotDone') return
|
|
1665
1907
|
if (k === 'recap') { ex.recap = ex.recap ? ex.recap + ' ' + v : v; return }
|
|
1666
1908
|
if (k === 'files') { const f = parseFileNote(v); if (f.path || f.why) ex.files.push(f); return }
|
|
1667
1909
|
if (k === 'decided') { const d = parseDecidedLine(v); if (d) ex.decided.push(d); return }
|
|
@@ -1910,7 +2152,7 @@ export const TITLE_LANGUAGE = 'Write it in the same language as the user\'s own
|
|
|
1910
2152
|
export const TITLE_SYSTEM = 'Write a title of at most 6 words for a coding session, based on its first prompt. ' + TITLE_LANGUAGE + ' Reply with the title only, no quotes, no punctuation at the end.'
|
|
1911
2153
|
|
|
1912
2154
|
export function cleanTitle(text) {
|
|
1913
|
-
const t = String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '').split('\n').map((l) => l.trim()).find(Boolean) || ''
|
|
2155
|
+
const t = stripChannel(String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '')).split('\n').map((l) => l.trim()).find(Boolean) || '' // 0.9.18: a model that echoes the Live wrapper
|
|
1914
2156
|
return clip(t.replace(/^title\s*:\s*/i, '').replace(/^["'`*]+|["'`*.]+$/g, ''), 60)
|
|
1915
2157
|
}
|
|
1916
2158
|
|
package/mod/hooks/register.js
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
shortId, formatTabTitle, parseTabTitle, tabRenameWaits, detectTerminal, parseProcLine, trailingQuestion,
|
|
20
20
|
parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
|
|
21
21
|
listHintFrom, backgroundListFrom, extractChecklists, parseTodoArgs, parseDoneArgs, detectBackend, parseParkArgs,
|
|
22
|
-
boardLinkLines, isNonInteractive, isLeftoffHeadless, lockForPark, parkLockHeld, unlockParkLocks, parkWorktreeText,
|
|
22
|
+
boardLinkLines, isNonInteractive, isLeftoffHeadless, lockForPark, parkLockHeld, unlockParkLocks, parkWorktreeText, reconcileWithWork,
|
|
23
23
|
} from './core.js'
|
|
24
24
|
|
|
25
25
|
const PANE_ID = 'checkpoints'
|
|
@@ -32,7 +32,7 @@ const START_SKIP_GRACE_MS = 15000 // the same for SessionStart (a cold start can
|
|
|
32
32
|
const PERM_GRACE_MS = 1500 // how long a permission prompt tool.check saw waits for classic.PermissionRequest before the mod reports it
|
|
33
33
|
|
|
34
34
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
35
|
-
const MOD_VERSION = '0.9.18
|
|
35
|
+
const MOD_VERSION = '0.9.18' // keep in step with .claude-plugin/plugin.json
|
|
36
36
|
|
|
37
37
|
const st = {
|
|
38
38
|
internal: false,
|
|
@@ -866,6 +866,11 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
|
|
|
866
866
|
}
|
|
867
867
|
// 0.9.2: nothing to go on (no prompt, no reply): don't save a placeholder over the last real one
|
|
868
868
|
if (isEmptyCheckpoint(parsed)) { if (trigger === 'manual') $.ui.toast(NOTHING_TO_CHECKPOINT, { timeoutMs: 6000 }); return null }
|
|
869
|
+
// 2026-11-06: a Stopped at / Next that calls a PR this session merged "ready to merge": the facts win
|
|
870
|
+
if (parsed.format !== 'markdown') {
|
|
871
|
+
const fix = reconcileWithWork(parsed, ctx.work)
|
|
872
|
+
if (fix.corrected.length) { parsed = { ...parsed, stoppedAt: fix.stoppedAt, next: fix.next }; generator = { ...(generator || {}), corrected: fix.corrected.join(' ') } }
|
|
873
|
+
}
|
|
869
874
|
if (opts.next) parsed = { ...parsed, next: opts.next }
|
|
870
875
|
const cp = {
|
|
871
876
|
sessionId: st.sessionId, createdAt: nowIso(), trigger, machine: st.machine,
|
|
@@ -874,6 +879,7 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
|
|
|
874
879
|
doing: parsed.doing, stoppedAt: parsed.stoppedAt, next: parsed.next, openQuestion: parsed.openQuestion || null,
|
|
875
880
|
userNote: userNote || null, filesTouched: ctx.filesTouched, turnsSinceLast: record.turnsSinceCheckpoint || 0,
|
|
876
881
|
parseOk: parsed.parseOk, ...(parsed.format === 'markdown' ? { format: 'markdown' } : {}), generator, length: ctx.length,
|
|
882
|
+
work: ctx.work || [], // 2026-11-06: what the commands' output shows got done
|
|
877
883
|
decided: mergeDecided(ctx.found, generator && generator.by === 'heuristic' ? [] : parsed.decided, { earlier: st.lastCheckpoint && st.lastCheckpoint.decided }), // 2026-11-03 decision memory
|
|
878
884
|
decisions: parsed.decisions || [], questions: parsed.questions || [], files: parsed.files || [],
|
|
879
885
|
didntWork: parsed.didntWork || [], recap: parsed.recap || '', nextSteps: parsed.nextSteps || [],
|
|
@@ -1107,14 +1113,22 @@ function startReplyLoop($) {
|
|
|
1107
1113
|
let backoff = 0
|
|
1108
1114
|
while (st.replyLoop && st.sessionId) {
|
|
1109
1115
|
const sid = st.sessionId
|
|
1110
|
-
const r = await api($, 'GET', '/v1/sessions/' + encodeURIComponent(sid) + '/replies/next?ms=' + ASK_POLL_MS, undefined, ASK_POLL_MS + 5000)
|
|
1116
|
+
const r = await api($, 'GET', '/v1/sessions/' + encodeURIComponent(sid) + '/replies/next?cmds=1&ms=' + ASK_POLL_MS, undefined, ASK_POLL_MS + 5000)
|
|
1111
1117
|
if (!r || !r.ok) {
|
|
1112
1118
|
// Daemon down: try again on the heartbeat rather than spinning.
|
|
1113
1119
|
st.replyLoop = false
|
|
1114
1120
|
return
|
|
1115
1121
|
}
|
|
1116
1122
|
const reply = r.json && r.json.reply
|
|
1117
|
-
|
|
1123
|
+
// a slash command from the board (the daemon checked it against its list):
|
|
1124
|
+
// Claude Code runs it as if typed, queued behind a running turn (as /rename)
|
|
1125
|
+
if (reply && reply.command && typeof reply.command.name === 'string' && /^[a-z][\w-]{0,30}$/.test(reply.command.name) && sid === st.sessionId) {
|
|
1126
|
+
try {
|
|
1127
|
+
await $.command.run({ command: reply.command.name, args: String(reply.command.args || '').replace(/[\r\n]+/g, ' ').slice(0, 500) })
|
|
1128
|
+
} catch (err) {
|
|
1129
|
+
try { $.ui.toast('Leftoff: couldn’t run the board’s /' + reply.command.name + ': ' + String((err && err.message) || err).slice(0, 120)) } catch {}
|
|
1130
|
+
}
|
|
1131
|
+
} else if (reply && reply.text && !String(reply.text).startsWith('/') && sid === st.sessionId) {
|
|
1118
1132
|
try {
|
|
1119
1133
|
await $.prompt.submit({ text: String(reply.text) })
|
|
1120
1134
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.18
|
|
3
|
+
"version": "0.9.18",
|
|
4
4
|
"description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|