@leftoff/cli 0.8.7 → 0.8.9
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 +16 -0
- package/bin/leftoff.js +371 -367
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +154 -2
- package/mod/hooks/register.js +36 -6
- package/package.json +1 -1
- package/ui/app.css +102 -16
- package/ui/app.js +107 -16
- package/ui/changelog-md.js +6 -2
- package/ui/handoffs.js +62 -5
- package/ui/icons.js +2 -0
- package/ui/prs.js +81 -37
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.9",
|
|
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": "Joel"
|
package/mod/hooks/core.js
CHANGED
|
@@ -298,7 +298,8 @@ const CP_ORDER = ['id', 'session_id', 'session_title', 'created_at', 'trigger',
|
|
|
298
298
|
export function resumeCommand(session) {
|
|
299
299
|
const id = session.id || session.sessionId
|
|
300
300
|
const cwd = resumeDir(session)
|
|
301
|
-
|
|
301
|
+
const wt = resumeWorktreeName(session)
|
|
302
|
+
return (cwd ? 'cd ' + shArg(cwd) + ' && ' : '') + (isCodex(session) ? codexInvocation(id) : claudeInvocation(session, [...(wt ? ['--worktree', wt] : []), '--resume', id]))
|
|
302
303
|
}
|
|
303
304
|
|
|
304
305
|
// 0.8.0: a Codex session (its rollout under ~/.codex/sessions) resumes with `codex resume <id>`.
|
|
@@ -308,9 +309,24 @@ export function codexInvocation(id) { return 'codex resume ' + shArg(id) }
|
|
|
308
309
|
// 0.7.1: where to run `claude --resume`: the session's folder, or, inside a git
|
|
309
310
|
// worktree, that worktree's own top folder (Claude Code declines to re-enter a
|
|
310
311
|
// `git worktree add` worktree from one of its subfolders; code.claude.com/docs/en/worktrees).
|
|
312
|
+
//
|
|
313
|
+
// 0.8.8: a worktree Claude Code made (<repo>/.claude/worktrees/<name>, from
|
|
314
|
+
// `claude --worktree` or EnterWorktree) is resumed the way Claude Code itself
|
|
315
|
+
// prints on exit: from the main checkout, `claude --worktree <name> --resume <id>`.
|
|
316
|
+
// On exit Claude Code moves the session back to where it was launched and
|
|
317
|
+
// clears its worktree binding, so --worktree is what puts it back in there.
|
|
318
|
+
// Not when the worktree is gone (folderGone): --worktree would make a new one.
|
|
319
|
+
export function resumeWorktreeName(session) {
|
|
320
|
+
const wt = session && session.repo && session.repo.worktree
|
|
321
|
+
const root = session && session.repo && session.repo.root
|
|
322
|
+
if (!wt || !root || typeof wt !== 'string' || session.folderGone || isCodex(session)) return null
|
|
323
|
+
const c = claudeWorktreeOf(wt)
|
|
324
|
+
return c && trimSlash(c.root) === trimSlash(root) && trimSlash(c.path) === trimSlash(wt) ? c.name : null
|
|
325
|
+
}
|
|
311
326
|
export function resumeDir(session) {
|
|
312
327
|
const cwd = session && session.cwd
|
|
313
328
|
const wt = session && session.repo && session.repo.worktree
|
|
329
|
+
if (resumeWorktreeName(session)) return session.repo.root
|
|
314
330
|
if (cwd && wt && typeof wt === 'string' && (cwd === wt || String(cwd).startsWith(wt.replace(/\/+$/, '') + '/'))) return wt
|
|
315
331
|
return cwd || null
|
|
316
332
|
}
|
|
@@ -821,9 +837,14 @@ export function applyEvent(s, ev) {
|
|
|
821
837
|
// replayed older events (a Codex rollout read again) don't.
|
|
822
838
|
if (s.parked && ms(at) > ms(s.parked.at) && unparks(s, ev, d)) delete s.parked
|
|
823
839
|
switch (ev.type) {
|
|
824
|
-
case 'session.parked':
|
|
840
|
+
case 'session.parked': {
|
|
841
|
+
const prevLock = s.parked && s.parked.worktreeLock // parked again: the lock it already holds stays on record
|
|
825
842
|
s.parked = { at, note: d.note ? clip(String(d.note).replace(/\s+/g, ' ').trim(), 300) || null : null, checkpoint: d.checkpoint ? String(d.checkpoint) : null, by: d.by ? String(d.by) : null }
|
|
843
|
+
// 0.8.8: the worktree /cp-park (or Park) locked, so it can be unlocked later
|
|
844
|
+
if (d.worktreeLock && typeof d.worktreeLock === 'object' && typeof d.worktreeLock.path === 'string' && typeof d.worktreeLock.reason === 'string') s.parked.worktreeLock = { path: d.worktreeLock.path, root: typeof d.worktreeLock.root === 'string' ? d.worktreeLock.root : null, reason: d.worktreeLock.reason }
|
|
845
|
+
else if (prevLock) s.parked.worktreeLock = prevLock
|
|
826
846
|
break
|
|
847
|
+
}
|
|
827
848
|
case 'session.unparked':
|
|
828
849
|
delete s.parked
|
|
829
850
|
break
|
|
@@ -912,6 +933,137 @@ export function parseParkArgs(args) {
|
|
|
912
933
|
return { note: note ? clip(note, 300) : null }
|
|
913
934
|
}
|
|
914
935
|
|
|
936
|
+
// ------------------------------------------------------------ parked worktrees (0.8.8)
|
|
937
|
+
// Claude Code removes a session's worktree when the session exits and the
|
|
938
|
+
// worktree has no changes (code.claude.com/docs/en/worktrees, "Clean up
|
|
939
|
+
// worktrees"), and /cp-park exits. So in a linked git worktree, park first
|
|
940
|
+
// locks it (`git worktree lock --reason …`). Claude Code's exit cleanup keeps a
|
|
941
|
+
// worktree locked with a reason it didn't write, and its stale-lock sweep only
|
|
942
|
+
// releases its own "claude agent|session … (pid N)" locks, so the reason must
|
|
943
|
+
// never start with "claude ". Resume, Done with it, or the session coming back
|
|
944
|
+
// unlocks it. Only a lock with this exact session's reason is ever unlocked.
|
|
945
|
+
// run(argv) -> { code | exitCode, stdout, stderr }: $.process.run in the mod,
|
|
946
|
+
// execFile in the daemon, a fake in tests. Nothing here deletes anything.
|
|
947
|
+
export const PARK_LOCK_PREFIX = 'leftoff: parked session '
|
|
948
|
+
export function parkLockReason(sessionId) {
|
|
949
|
+
return PARK_LOCK_PREFIX + String(sessionId || '') + ' (resume it, or press Done with it on the board, to unlock)'
|
|
950
|
+
}
|
|
951
|
+
// a lock reason -> the session id Leftoff parked, or null for any other lock
|
|
952
|
+
export function parkLockSession(reason) {
|
|
953
|
+
const m = /^leftoff: parked session ([A-Za-z0-9-]{4,80})(?: |$)/.exec(String(reason == null ? '' : reason))
|
|
954
|
+
return m ? m[1] : null
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// `git worktree list --porcelain` -> [{ path, branch, bare, detached, locked: null | reason ('' when none given) }]
|
|
958
|
+
export function parseWorktreeList(text) {
|
|
959
|
+
const out = []
|
|
960
|
+
let cur = null
|
|
961
|
+
for (const raw of String(text || '').split('\n')) {
|
|
962
|
+
const line = raw.replace(/\r$/, '')
|
|
963
|
+
if (line.startsWith('worktree ')) { cur = { path: line.slice(9), branch: null, bare: false, detached: false, locked: null }; out.push(cur); continue }
|
|
964
|
+
if (!cur) continue
|
|
965
|
+
if (line.startsWith('branch ')) cur.branch = line.slice(7).replace(/^refs\/heads\//, '')
|
|
966
|
+
else if (line === 'bare') cur.bare = true
|
|
967
|
+
else if (line === 'detached') cur.detached = true
|
|
968
|
+
else if (line === 'locked') cur.locked = ''
|
|
969
|
+
else if (line.startsWith('locked ')) cur.locked = line.slice(7)
|
|
970
|
+
}
|
|
971
|
+
return out
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const runCode = (r) => (r == null ? null : r.code != null ? r.code : r.exitCode != null ? r.exitCode : null)
|
|
975
|
+
const trimSlash = (p) => String(p || '').replace(/\/+$/, '') || '/'
|
|
976
|
+
const firstLine = (s) => String(s || '').trim().split('\n')[0].slice(0, 200)
|
|
977
|
+
// a path inside Claude Code's own worktrees folder: <repo>/.claude/worktrees/<name>[/…]
|
|
978
|
+
export function claudeWorktreeOf(dir) {
|
|
979
|
+
const m = /^(.*)\/\.claude\/worktrees\/([^/]+)(?:\/.*)?$/.exec(trimSlash(dir))
|
|
980
|
+
return m ? { root: m[1] || '/', name: m[2], path: m[1] + '/.claude/worktrees/' + m[2] } : null
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// Is dir in a linked git worktree? -> { kind: 'worktree', top, root, gitDir, commonDir }
|
|
984
|
+
// | { kind: 'none' } (not in git, or the main checkout) | { kind: 'unknown', why }
|
|
985
|
+
export async function worktreeAt(run, dir) {
|
|
986
|
+
if (!dir) return { kind: 'unknown', why: 'no folder on record' }
|
|
987
|
+
let r
|
|
988
|
+
try { r = await run(['git', '-C', dir, 'rev-parse', '--path-format=absolute', '--git-dir', '--git-common-dir', '--show-toplevel']) } catch (e) { return { kind: 'unknown', why: 'git didn’t run (' + firstLine(e && e.message) + ')' } }
|
|
989
|
+
if (runCode(r) !== 0) {
|
|
990
|
+
if (/not a git repository/i.test(String(r && r.stderr))) return claudeWorktreeOf(dir) ? { kind: 'unknown', why: 'git doesn’t see a repository there' } : { kind: 'none' }
|
|
991
|
+
return { kind: 'unknown', why: 'git: ' + (firstLine(r && r.stderr) || 'exit ' + runCode(r)) }
|
|
992
|
+
}
|
|
993
|
+
const [gitDir, commonDir, top] = String(r.stdout || '').trim().split('\n').map((x) => trimSlash(x.trim()))
|
|
994
|
+
if (!gitDir || !commonDir || !top) return { kind: 'unknown', why: 'git gave no answer' }
|
|
995
|
+
if (gitDir === commonDir) return { kind: 'none' } // the main checkout (or a submodule, its own repo)
|
|
996
|
+
return { kind: 'worktree', top, gitDir, commonDir, root: /\/\.git$/.test(commonDir) ? commonDir.slice(0, -5) || '/' : commonDir }
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
function findEntry(list, path, reason) {
|
|
1000
|
+
const p = trimSlash(path)
|
|
1001
|
+
return list.find((e) => trimSlash(e.path) === p) || (reason ? list.find((e) => e.locked === reason) : null) || null
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// /cp-park (and Park on the board): lock the worktree dir is in, then read it
|
|
1005
|
+
// back. -> { worktree: null } (not a worktree: nothing to protect)
|
|
1006
|
+
// | { worktree, root, locked: true, reason } (locked and checked)
|
|
1007
|
+
// | { worktree, root?, locked: false, why } (not protected: don't exit for them)
|
|
1008
|
+
export async function lockForPark(run, dir, sessionId) {
|
|
1009
|
+
const w = await worktreeAt(run, dir)
|
|
1010
|
+
if (w.kind === 'none') return { worktree: null }
|
|
1011
|
+
if (w.kind === 'unknown') { const g = claudeWorktreeOf(dir); return { worktree: g ? g.path : null, locked: false, why: w.why, unsure: true } }
|
|
1012
|
+
const reason = parkLockReason(sessionId)
|
|
1013
|
+
let lockErr = ''
|
|
1014
|
+
try { const r = await run(['git', '-C', w.top, 'worktree', 'lock', '--reason', reason, w.top]); if (runCode(r) !== 0) lockErr = firstLine(r && r.stderr) || 'exit ' + runCode(r) } catch (e) { lockErr = firstLine(e && e.message) }
|
|
1015
|
+
let list = null
|
|
1016
|
+
try { const l = await run(['git', '-C', w.top, 'worktree', 'list', '--porcelain']); if (runCode(l) === 0) list = parseWorktreeList(l.stdout) } catch {}
|
|
1017
|
+
const base = { worktree: w.top, root: w.root }
|
|
1018
|
+
if (!list) return { ...base, locked: false, why: 'couldn’t read the lock back' + (lockErr ? ' (' + lockErr + ')' : '') }
|
|
1019
|
+
const e = findEntry(list, w.top, reason)
|
|
1020
|
+
if (e && e.locked === reason) return { ...base, locked: true, reason }
|
|
1021
|
+
if (e && e.locked != null) return { ...base, locked: false, why: 'it’s already locked' + (e.locked ? ' (“' + clip(e.locked, 80) + '”)' : ''), foreign: true }
|
|
1022
|
+
return { ...base, locked: false, why: lockErr || 'the lock didn’t take' }
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Is the lock still there, with our reason? (checked again right before /exit)
|
|
1026
|
+
export async function parkLockHeld(run, path, reason) {
|
|
1027
|
+
try {
|
|
1028
|
+
const l = await run(['git', '-C', path, 'worktree', 'list', '--porcelain'])
|
|
1029
|
+
if (runCode(l) !== 0) return false
|
|
1030
|
+
const e = findEntry(parseWorktreeList(l.stdout), path, reason)
|
|
1031
|
+
return !!(e && e.locked === reason)
|
|
1032
|
+
} catch { return false }
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Unlock what Leftoff locked for this session: every worktree of the repo at
|
|
1036
|
+
// dir whose lock reason is this session's park reason. Any other lock (yours,
|
|
1037
|
+
// Claude Code's) is left alone. -> { unlocked: [path], failed: [{ path, why }], error? }
|
|
1038
|
+
export async function unlockParkLocks(run, dir, sessionId) {
|
|
1039
|
+
const out = { unlocked: [], failed: [] }
|
|
1040
|
+
if (!dir || !sessionId) return out
|
|
1041
|
+
let list
|
|
1042
|
+
try {
|
|
1043
|
+
const l = await run(['git', '-C', dir, 'worktree', 'list', '--porcelain'])
|
|
1044
|
+
if (runCode(l) !== 0) return { ...out, error: firstLine(l && l.stderr) || 'git worktree list failed' }
|
|
1045
|
+
list = parseWorktreeList(l.stdout)
|
|
1046
|
+
} catch (e) { return { ...out, error: firstLine(e && e.message) } }
|
|
1047
|
+
for (const e of list) {
|
|
1048
|
+
if (e.locked == null || parkLockSession(e.locked) !== String(sessionId)) continue
|
|
1049
|
+
try {
|
|
1050
|
+
const r = await run(['git', '-C', dir, 'worktree', 'unlock', e.path])
|
|
1051
|
+
if (runCode(r) === 0) out.unlocked.push(e.path)
|
|
1052
|
+
else out.failed.push({ path: e.path, why: firstLine(r && r.stderr) || 'exit ' + runCode(r) })
|
|
1053
|
+
} catch (err) { out.failed.push({ path: e.path, why: firstLine(err && err.message) }) }
|
|
1054
|
+
}
|
|
1055
|
+
return out
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// What /cp-park says about the worktree. res: lockForPark's answer.
|
|
1059
|
+
export function parkWorktreeText(res) {
|
|
1060
|
+
if (!res || !res.worktree && !res.unsure) return ''
|
|
1061
|
+
if (!res.worktree && !res.locked) return ' Leftoff couldn’t tell whether this is a worktree session (' + (res.why || 'no answer') + '), so it didn’t close it. If it is one, lock it first (`git worktree lock <worktree>`) or choose Keep if Claude Code asks: on /exit, Claude Code removes a worktree that has no changes.'
|
|
1062
|
+
if (res.locked) return ' This is a worktree session, so Leftoff locked the worktree first: Claude Code will say it kept it (“could not be removed”). Resume or Done with it unlocks it.'
|
|
1063
|
+
const where = res.worktree ? ' ' + shArg(res.worktree) : ''
|
|
1064
|
+
return ' This is a worktree session and Leftoff couldn’t lock it (' + (res.why || 'no answer') + '), so it didn’t close the session: on /exit, Claude Code removes a worktree that has no changes. To keep it, run `git worktree lock' + (where || ' <worktree>') + '` first, or choose Keep if Claude Code asks.'
|
|
1065
|
+
}
|
|
1066
|
+
|
|
915
1067
|
// The question Claude ended its turn on, if it ended on one: the last paragraph
|
|
916
1068
|
// of the answer when its last sentence ends with "?" (markdown and closing
|
|
917
1069
|
// quotes ignored). Code blocks don't count. -> string or null
|
package/mod/hooks/register.js
CHANGED
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
shortId, formatTabTitle, parseTabTitle, detectTerminal, parseProcLine, trailingQuestion,
|
|
20
20
|
parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
|
|
21
21
|
listHintFrom, extractChecklists, parseTodoArgs, parseDoneArgs, detectBackend, parseParkArgs,
|
|
22
|
-
boardLinkLines, isNonInteractive,
|
|
22
|
+
boardLinkLines, isNonInteractive, lockForPark, parkLockHeld, unlockParkLocks, parkWorktreeText,
|
|
23
23
|
} from './core.js'
|
|
24
24
|
|
|
25
25
|
const PANE_ID = 'checkpoints'
|
|
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
|
|
30
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.8.
|
|
31
|
+
const MOD_VERSION = '0.8.9' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
|
@@ -278,17 +278,45 @@ async function boardLinks($) {
|
|
|
278
278
|
// only runs once Claude is done with the turn). If that's refused, it says to
|
|
279
279
|
// type /exit. Nothing is ever killed.
|
|
280
280
|
const PARK_FALLBACK = 'Parked. Type /exit when you\'re ready, and it\'ll stay on your board.'
|
|
281
|
+
const PARK_LOCK_LOST = 'Parked, but not closed: the worktree lock Leftoff took is gone, and on /exit Claude Code removes a worktree that has no changes. Lock it again (git worktree lock), or choose Keep if Claude Code asks.'
|
|
282
|
+
// 0.8.8: argv -> { exitCode, stdout, stderr } for the worktree helpers in core.js
|
|
283
|
+
async function gitFor($, argv) { return $.process.run(argv, { timeoutMs: 5000 }) }
|
|
281
284
|
async function parkCommand($, args) {
|
|
282
285
|
if (st.internal || !st.sessionId) return { text: 'Leftoff: not in a tracked session.' }
|
|
283
286
|
const a = parseParkArgs(args)
|
|
287
|
+
// 0.8.8: in a git worktree, lock it first: Claude Code's exit removes a worktree
|
|
288
|
+
// with no changes, and parking means coming back. No lock, no automatic /exit.
|
|
289
|
+
let cwd = null
|
|
290
|
+
try { cwd = await $.session.cwd() } catch {}
|
|
291
|
+
cwd = cwd || (st.record && st.record.cwd) || null
|
|
292
|
+
const run = (argv) => gitFor($, argv)
|
|
293
|
+
const wt = cwd ? await lockForPark(run, cwd, st.sessionId).catch((e) => ({ worktree: null, locked: false, unsure: true, why: String(e && e.message) })) : { worktree: null, locked: false, unsure: true, why: 'no folder on record' }
|
|
284
294
|
const cp = await makeCheckpoint($, 'park', a.note, null, { next: a.note })
|
|
285
|
-
if (!cp)
|
|
286
|
-
|
|
287
|
-
|
|
295
|
+
if (!cp) {
|
|
296
|
+
if (wt.locked) await unlockParkLocks(run, wt.root || wt.worktree, st.sessionId).catch(() => null) // not parked after all
|
|
297
|
+
return { text: 'Leftoff: a checkpoint is already being made. Try /cp-park again in a moment.' }
|
|
298
|
+
}
|
|
299
|
+
const worktreeLock = wt.locked ? { path: wt.worktree, root: wt.root || null, reason: wt.reason } : null
|
|
300
|
+
await sendOrSpool($, 'session.parked', { note: a.note, checkpoint: cp.id || null, by: 'command', ...(worktreeLock ? { worktreeLock } : {}) })
|
|
301
|
+
const head = 'Parked' + (a.note ? ': ' + clip(a.note, 120) : '') + '.'
|
|
302
|
+
if (wt.worktree || wt.unsure) {
|
|
303
|
+
if (!wt.locked) return { text: head + parkWorktreeText(wt) }
|
|
304
|
+
}
|
|
305
|
+
// After this command returns, not inside it: the built-in /exit (in a worktree, only while our lock still holds).
|
|
288
306
|
$.clock.after(400, async () => {
|
|
307
|
+
if (worktreeLock && !(await parkLockHeld(run, worktreeLock.path, worktreeLock.reason))) { try { $.ui.toast(PARK_LOCK_LOST, { timeoutMs: 12000 }) } catch {} return }
|
|
289
308
|
try { await $.command.run({ command: 'exit' }) } catch { try { $.ui.toast(PARK_FALLBACK, { timeoutMs: 8000 }) } catch {} }
|
|
290
309
|
})
|
|
291
|
-
return { text:
|
|
310
|
+
return { text: head + ' Closing this session; it stays on your board under Parked.' + parkWorktreeText(wt) }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 0.8.8: this session is back (claude --resume, or --worktree <name> --resume):
|
|
314
|
+
// unlock any worktree Leftoff locked when it was parked. Only its own lock.
|
|
315
|
+
async function unlockOwnParkLocks($, cwd) {
|
|
316
|
+
if (st.internal || !st.sessionId || !cwd) return
|
|
317
|
+
try {
|
|
318
|
+
await unlockParkLocks((argv) => gitFor($, argv), cwd, st.sessionId)
|
|
319
|
+
} catch {}
|
|
292
320
|
}
|
|
293
321
|
|
|
294
322
|
// ------------------------------------------------------------ lists (v0.5.1)
|
|
@@ -901,6 +929,7 @@ export function register(on, options) {
|
|
|
901
929
|
st.backend = await readBackend($, model)
|
|
902
930
|
st.hello = { cwd, repo, model, backend: st.backend, surface, proc: st.proc ? { ...st.proc, source: 'mod' } : null, term: st.term }
|
|
903
931
|
await sendOrSpool($, 'session.opened', st.hello)
|
|
932
|
+
if (repo) await unlockOwnParkLocks($, cwd) // 0.8.8
|
|
904
933
|
if (st.heartbeat) st.heartbeat.cancel()
|
|
905
934
|
st.heartbeat = $.clock.every(60000, () => { heartbeat($).catch(() => {}); startReplyLoop($) })
|
|
906
935
|
st.ready = true
|
|
@@ -923,6 +952,7 @@ export function register(on, options) {
|
|
|
923
952
|
st.lastCheckpoint = null
|
|
924
953
|
st.titleSoon = false
|
|
925
954
|
st.daemonTitle = null
|
|
955
|
+
if (e.source === 'resume' && e.cwd) await unlockOwnParkLocks($, e.cwd) // 0.8.8: /resume of a parked worktree session
|
|
926
956
|
}
|
|
927
957
|
if (!st.sessionId) st.sessionId = e.session_id
|
|
928
958
|
await sendOrSpool($, 'session.started', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.9",
|
|
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",
|
package/ui/app.css
CHANGED
|
@@ -110,6 +110,14 @@ h2 .count { color: var(--faint); font-weight: 500; }
|
|
|
110
110
|
.card .row1 { display: flex; align-items: flex-start; gap: 10px; }
|
|
111
111
|
.card .title { font-size: 15px; font-weight: 600; line-height: 1.35; flex: 1; }
|
|
112
112
|
.card .title a:hover { text-decoration: underline; text-decoration-color: var(--faint); }
|
|
113
|
+
.s-title { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
|
114
|
+
.s-title h1 { min-width: 0; }
|
|
115
|
+
.s-title-text { cursor: text; border-radius: 6px; outline-offset: 2px; }
|
|
116
|
+
.s-title-text:hover { background: var(--subtle); box-shadow: 0 0 0 4px var(--subtle); }
|
|
117
|
+
.s-title .rename-pen { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 8px; border: 1px solid transparent; background: none; color: var(--muted); cursor: pointer; }
|
|
118
|
+
.s-title .rename-pen:hover, .s-title .rename-pen:focus-visible { color: var(--accent); border-color: var(--line); background: var(--bg2); }
|
|
119
|
+
.s-title .rename-pen svg { width: 16px; height: 16px; }
|
|
120
|
+
.s-title input { flex: 1 1 auto; width: min(560px, calc(100vw - 80px)); min-width: 0; font-family: inherit; font-weight: 700; font-size: 22px; line-height: 1.25; color: var(--text); background: var(--field); border: 1px solid var(--accent); border-radius: 8px; padding: 4px 8px; margin: 6px 0; }
|
|
113
121
|
.card .title input { width: 100%; background: var(--bg); border: 1px solid var(--accent); border-radius: 6px; color: var(--text); padding: 3px 6px; font: inherit; }
|
|
114
122
|
.mt { margin-top: 20px; }
|
|
115
123
|
.icon { background: none; border: 0; color: var(--faint); cursor: pointer; padding: 0 4px; margin-left: 4px; font-size: 13px; opacity: 0; transition: opacity .15s; }
|
|
@@ -1432,6 +1440,17 @@ a.cpr:hover { border-color: var(--line-strong); }
|
|
|
1432
1440
|
.prs-q { width: 100%; max-width: 480px; font-size: 16px; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--card); color: var(--text); }
|
|
1433
1441
|
.prs-chips { margin-top: 8px; }
|
|
1434
1442
|
.prs-chips .rchip.ck .rl::before { content: '◇ '; color: var(--faint); }
|
|
1443
|
+
/* 0.8.9: the buckets, each with its color dot (chips) and heading color (groups) */
|
|
1444
|
+
.prs-buckets .rchip .rl::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 6px; vertical-align: 1px; background: var(--b, var(--gray)); }
|
|
1445
|
+
.prs-buckets .rchip { font-family: inherit; }
|
|
1446
|
+
.rchip.b-review, .prgroup.b-review { --b: var(--accent); }
|
|
1447
|
+
.rchip.b-fix, .prgroup.b-fix { --b: var(--red); }
|
|
1448
|
+
.rchip.b-ready, .prgroup.b-ready { --b: var(--green); }
|
|
1449
|
+
.rchip.b-waiting, .prgroup.b-waiting { --b: var(--yellow); }
|
|
1450
|
+
.rchip.b-merged, .prgroup.b-merged { --b: var(--machine); }
|
|
1451
|
+
.rchip.b-closed, .prgroup.b-closed { --b: var(--gray); }
|
|
1452
|
+
.prgroup[class*="b-"] > h2 { color: var(--b); }
|
|
1453
|
+
.prgroup[class*="b-"] > h2::before { content: ''; display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 7px; vertical-align: 1px; background: var(--b); }
|
|
1435
1454
|
.pr-why { color: var(--muted); }
|
|
1436
1455
|
.prrow { cursor: pointer; }
|
|
1437
1456
|
.prrow.open { border-color: var(--line-strong); }
|
|
@@ -1556,30 +1575,97 @@ ul.files-list li + li { border-top: 1px solid var(--line); }
|
|
|
1556
1575
|
.doc-viewer { position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,.55); display: flex; align-items: stretch; justify-content: center; padding: max(16px, env(safe-area-inset-top)) 12px 12px; }
|
|
1557
1576
|
.doc-viewer[hidden] { display: none; }
|
|
1558
1577
|
html.dv-open, html.dv-open body { overflow: hidden; }
|
|
1559
|
-
.dv-sheet { width:
|
|
1560
|
-
.dv-head { display: flex; align-items: center; gap:
|
|
1578
|
+
.dv-sheet { width: min(1100px, 92vw); max-width: 100%; background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius); display: flex; flex-direction: column; overflow: hidden; box-shadow: var(--toast-shadow); }
|
|
1579
|
+
.dv-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px 10px 16px; border-bottom: 1px solid var(--line); }
|
|
1561
1580
|
.dv-title { flex: 1; min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1562
1581
|
.dv-x { flex: none; width: 36px; height: 36px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg2); color: var(--text); font-size: 20px; line-height: 1; cursor: pointer; }
|
|
1563
|
-
.dv-
|
|
1564
|
-
.dv-
|
|
1565
|
-
.
|
|
1566
|
-
.
|
|
1567
|
-
.
|
|
1568
|
-
.
|
|
1569
|
-
.
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
.doc-
|
|
1582
|
+
.dv-dl { flex: none; display: inline-flex; align-items: center; gap: 6px; }
|
|
1583
|
+
.dv-dl svg { width: 15px; height: 15px; }
|
|
1584
|
+
.dv-dl .chev { font-size: 10px; opacity: .7; margin-left: 1px; }
|
|
1585
|
+
.dv-dl.copied { color: var(--green); border-color: var(--green); }
|
|
1586
|
+
.dv-ptitle { display: none; }
|
|
1587
|
+
.dv-sub { padding: 6px 16px 0; font-size: 12px; }
|
|
1588
|
+
.dv-body { flex: 1; overflow: auto; padding: 10px 24px 32px; -webkit-overflow-scrolling: touch; }
|
|
1589
|
+
/* 0.8.9: a phone gets the whole screen (a sheet), not a card with margins */
|
|
1590
|
+
@media (max-width: 599.98px) {
|
|
1591
|
+
.doc-viewer { padding: 0; }
|
|
1592
|
+
.dv-sheet { width: 100%; border: 0; border-radius: 0; box-shadow: none; padding-top: env(safe-area-inset-top); }
|
|
1593
|
+
.dv-head { padding: 8px 8px 8px 14px; }
|
|
1594
|
+
.dv-body { padding: 8px 16px calc(28px + env(safe-area-inset-bottom)); }
|
|
1595
|
+
.dv-sub { padding: 6px 14px 0; }
|
|
1596
|
+
.dv-dl .dl-word { display: none; }
|
|
1597
|
+
}
|
|
1598
|
+
html.dv-open .lo-backdrop { z-index: 61; }
|
|
1599
|
+
html.dv-open .lo-menu { z-index: 62; }
|
|
1600
|
+
|
|
1601
|
+
/* The rendered markdown (0.8.9: colors). Prose keeps a reading measure (~80
|
|
1602
|
+
characters); tables and code blocks may use the whole width. */
|
|
1603
|
+
.doc-md { font-size: 15px; line-height: 1.6; color: var(--text2); overflow-wrap: anywhere; }
|
|
1604
|
+
.doc-md > p, .doc-md > ul, .doc-md > ol, .doc-md > blockquote, .doc-md > h2, .doc-md > h3, .doc-md > h4, .doc-md > h5, .doc-md > h6 { max-width: 80ch; }
|
|
1605
|
+
.doc-md p { margin: 0 0 12px; }
|
|
1606
|
+
.doc-md h2, .doc-md h3, .doc-md h4, .doc-md h5, .doc-md h6 { text-transform: none; letter-spacing: normal; line-height: 1.3; }
|
|
1607
|
+
.doc-md h2 { font-size: 24px; font-weight: 700; color: var(--text); margin: 22px 0 12px; padding-bottom: 8px; border-bottom: 2px solid color-mix(in srgb, var(--accent) 45%, var(--line)); }
|
|
1608
|
+
.doc-md h2:first-child { margin-top: 6px; }
|
|
1609
|
+
.doc-md h3 { font-size: 19px; font-weight: 650; color: var(--accent); margin: 22px 0 8px; }
|
|
1610
|
+
.doc-md h4 { font-size: 16px; font-weight: 650; color: var(--cyan); margin: 18px 0 6px; }
|
|
1611
|
+
.doc-md h5, .doc-md h6 { font-size: 13px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; margin: 16px 0 6px; }
|
|
1612
|
+
.doc-md b, .doc-md strong { color: var(--text); font-weight: 650; }
|
|
1613
|
+
.doc-md em { font-style: italic; }
|
|
1614
|
+
.doc-md del { color: var(--muted); }
|
|
1615
|
+
.doc-md a { color: var(--accent); text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent); text-underline-offset: 2px; }
|
|
1616
|
+
.doc-md a:hover { text-decoration-color: var(--accent); }
|
|
1617
|
+
.doc-md code { font-family: var(--mono); font-size: .87em; color: var(--you); background: var(--tint-red); border-radius: 5px; padding: 1px 5px; }
|
|
1618
|
+
.doc-md pre.doc-code, .doc-pre { background: var(--card2); border: 1px solid var(--line); border-left: 3px solid color-mix(in srgb, var(--accent) 55%, var(--line)); border-radius: 10px; padding: 12px 14px; margin: 0 0 14px; overflow: auto; font-family: var(--mono); font-size: 12.5px; line-height: 1.5; white-space: pre; color: var(--text); }
|
|
1619
|
+
.doc-md pre.doc-code code { background: none; padding: 0; color: inherit; font-size: inherit; }
|
|
1620
|
+
.doc-md .tk-c { color: var(--faint); font-style: italic; }
|
|
1621
|
+
.doc-md .tk-s { color: var(--green); }
|
|
1622
|
+
.doc-md .tk-n { color: var(--yellow); }
|
|
1623
|
+
.doc-md .tk-k { color: var(--accent); font-weight: 600; }
|
|
1624
|
+
.doc-md .tk-add { color: var(--green); background: var(--tint-green); display: inline-block; min-width: 100%; }
|
|
1625
|
+
.doc-md .tk-del { color: var(--red); background: var(--tint-red); display: inline-block; min-width: 100%; }
|
|
1626
|
+
.doc-md blockquote { margin: 12px 0; padding: 8px 14px; border-left: 3px solid var(--accent); background: var(--tint-accent); border-radius: 0 8px 8px 0; color: var(--text2); }
|
|
1627
|
+
.doc-md blockquote > :last-child { margin-bottom: 0; }
|
|
1628
|
+
.doc-md ul, .doc-md ol { margin: 0 0 12px; padding-left: 22px; }
|
|
1629
|
+
.doc-md li { margin: 3px 0; }
|
|
1630
|
+
.doc-md li::marker { color: var(--accent); }
|
|
1631
|
+
.doc-md ol > li::marker { font-weight: 600; }
|
|
1573
1632
|
.doc-md li.sub { margin-left: 18px; }
|
|
1574
1633
|
.doc-md li.task { list-style: none; margin-left: -18px; }
|
|
1575
1634
|
.doc-page { max-width: 760px; padding-bottom: 32px; } /* 0.8.1: #/docs/<name> */
|
|
1576
|
-
.doc-md .tbox { display: inline-
|
|
1635
|
+
.doc-md .tbox { display: inline-flex; align-items: center; justify-content: center; width: 15px; height: 15px; border: 1.5px solid var(--line-strong); border-radius: 4px; margin-right: 8px; vertical-align: -2px; background: var(--field); }
|
|
1577
1636
|
.doc-md .tbox.on { background: var(--green); border-color: var(--green); }
|
|
1578
|
-
.doc-
|
|
1579
|
-
.doc-md
|
|
1637
|
+
.doc-md .tbox.on::after { content: ''; width: 4px; height: 8px; border: solid var(--on-accent); border-width: 0 2px 2px 0; transform: translateY(-1px) rotate(45deg); }
|
|
1638
|
+
.doc-md li.task:has(.tbox.on) { color: var(--muted); }
|
|
1639
|
+
.doc-md hr { border: 0; height: 2px; margin: 22px 0; background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 10%, transparent)); opacity: .55; }
|
|
1640
|
+
.doc-table { overflow-x: auto; margin: 0 0 14px; border: 1px solid var(--line); border-radius: 10px; }
|
|
1641
|
+
.doc-md table { border-collapse: collapse; font-size: 13.5px; width: max-content; min-width: 100%; }
|
|
1580
1642
|
.doc-md table { overflow-wrap: normal; word-break: normal; }
|
|
1581
1643
|
.doc-md th { white-space: nowrap; }
|
|
1582
|
-
.doc-md th, .doc-md td { border: 1px solid var(--line); padding:
|
|
1644
|
+
.doc-md th, .doc-md td { border-bottom: 1px solid var(--line); padding: 7px 12px; text-align: left; vertical-align: top; }
|
|
1645
|
+
.doc-md th + th, .doc-md td + td { border-left: 1px solid var(--line); }
|
|
1646
|
+
.doc-md thead th { background: var(--tint-accent); color: var(--text); font-weight: 650; border-bottom: 2px solid color-mix(in srgb, var(--accent) 40%, var(--line)); }
|
|
1647
|
+
.doc-md tbody tr:nth-child(even) td { background: var(--bg2); }
|
|
1648
|
+
.doc-md tbody tr:last-child td { border-bottom: 0; }
|
|
1649
|
+
|
|
1650
|
+
/* 0.8.9: Download as PDF prints just the file (the browser's "Save as PDF"):
|
|
1651
|
+
white paper, the light theme's colors, no modal around it. */
|
|
1652
|
+
@media print {
|
|
1653
|
+
html.dv-printing body > *:not(#docViewer) { display: none !important; }
|
|
1654
|
+
html.dv-printing, html.dv-printing body { overflow: visible !important; height: auto !important; background: #fff !important; }
|
|
1655
|
+
html.dv-printing #docViewer { position: static !important; display: block !important; background: none !important; padding: 0 !important; }
|
|
1656
|
+
html.dv-printing .dv-sheet { display: block; width: auto; max-width: none; border: 0; border-radius: 0; box-shadow: none; background: #fff; overflow: visible; }
|
|
1657
|
+
html.dv-printing .dv-head, html.dv-printing .dv-sub, html.dv-printing .lo-menu, html.dv-printing .lo-backdrop { display: none !important; }
|
|
1658
|
+
html.dv-printing .dv-ptitle { display: block; font: 600 12px/1.4 var(--mono); color: #687185; border-bottom: 1px solid #e1e5ed; padding-bottom: 6px; margin-bottom: 14px; }
|
|
1659
|
+
html.dv-printing .dv-body { overflow: visible; padding: 0; }
|
|
1660
|
+
html.dv-printing .doc-md { font-size: 11.5pt; }
|
|
1661
|
+
html.dv-printing .doc-md > * { max-width: none; }
|
|
1662
|
+
html.dv-printing .doc-md pre.doc-code, html.dv-printing .doc-pre { white-space: pre-wrap; overflow: visible; break-inside: avoid; }
|
|
1663
|
+
html.dv-printing .doc-table { overflow: visible; }
|
|
1664
|
+
html.dv-printing .doc-md table { width: 100%; }
|
|
1665
|
+
html.dv-printing .doc-md tr, html.dv-printing .doc-md blockquote { break-inside: avoid; }
|
|
1666
|
+
html.dv-printing .doc-md h2, html.dv-printing .doc-md h3, html.dv-printing .doc-md h4 { break-after: avoid; }
|
|
1667
|
+
html.dv-printing * { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
|
1668
|
+
}
|
|
1583
1669
|
.doc-img { text-align: center; padding-top: 10px; }
|
|
1584
1670
|
.doc-img img { max-width: 100%; height: auto; border-radius: 8px; background: #fff; }
|
|
1585
1671
|
|