@leftoff/cli 0.7.0 → 0.7.1
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 +7 -0
- package/bin/leftoff.js +243 -243
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +14 -3
- package/mod/hooks/register.js +22 -4
- package/package.json +1 -1
- package/ui/app.css +3 -0
- package/ui/app.js +14 -5
- package/ui/repos.js +3 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
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
|
@@ -284,10 +284,20 @@ const CP_ORDER = ['id', 'session_id', 'session_title', 'created_at', 'trigger',
|
|
|
284
284
|
|
|
285
285
|
export function resumeCommand(session) {
|
|
286
286
|
const id = session.id || session.sessionId
|
|
287
|
-
const cwd = session
|
|
287
|
+
const cwd = resumeDir(session)
|
|
288
288
|
return (cwd ? 'cd ' + shArg(cwd) + ' && ' : '') + claudeInvocation(session, ['--resume', id])
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
// 0.7.1: where to run `claude --resume`: the session's folder, or, inside a git
|
|
292
|
+
// worktree, that worktree's own top folder (Claude Code declines to re-enter a
|
|
293
|
+
// `git worktree add` worktree from one of its subfolders; code.claude.com/docs/en/worktrees).
|
|
294
|
+
export function resumeDir(session) {
|
|
295
|
+
const cwd = session && session.cwd
|
|
296
|
+
const wt = session && session.repo && session.repo.worktree
|
|
297
|
+
if (cwd && wt && typeof wt === 'string' && (cwd === wt || String(cwd).startsWith(wt.replace(/\/+$/, '') + '/'))) return wt
|
|
298
|
+
return cwd || null
|
|
299
|
+
}
|
|
300
|
+
|
|
291
301
|
// POSIX single quotes when needed (also right in fish).
|
|
292
302
|
export function shArg(s) {
|
|
293
303
|
const v = String(s)
|
|
@@ -494,7 +504,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
|
|
|
494
504
|
export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
|
|
495
505
|
'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
|
|
496
506
|
'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
|
|
497
|
-
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files']
|
|
507
|
+
'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos']
|
|
498
508
|
|
|
499
509
|
export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
|
|
500
510
|
'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
|
|
@@ -745,7 +755,8 @@ export function checkpointDue(s, now, cfg = DEFAULT_CONFIG) {
|
|
|
745
755
|
// "N empty sessions" row until their first prompt; the digest and restore skip them.
|
|
746
756
|
export function isEmptySession(s, checkpointCount = 0) {
|
|
747
757
|
if (!s) return true
|
|
748
|
-
|
|
758
|
+
// 0.7.1: a prompt counts. A long first turn (a worktree bug fix) has no completed turn yet.
|
|
759
|
+
return !(Number(s.turns) > 0) && !s.title && !s.firstPrompt && !(Number(checkpointCount) > 0) && !s.lastCheckpointId &&
|
|
749
760
|
!(Number(s.historyTurns) > 0) && s.startSource !== 'resume'
|
|
750
761
|
}
|
|
751
762
|
|
package/mod/hooks/register.js
CHANGED
|
@@ -27,7 +27,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
27
27
|
const MODEL_TIMEOUT_MS = 20000
|
|
28
28
|
|
|
29
29
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
30
|
-
const MOD_VERSION = '0.7.
|
|
30
|
+
const MOD_VERSION = '0.7.1' // keep in step with .claude-plugin/plugin.json
|
|
31
31
|
|
|
32
32
|
const st = {
|
|
33
33
|
internal: false,
|
|
@@ -828,6 +828,18 @@ function startReplyLoop($) {
|
|
|
828
828
|
loop().catch(() => { st.replyLoop = false })
|
|
829
829
|
}
|
|
830
830
|
|
|
831
|
+
// 0.7.1: Claude moved (cd, or into a git worktree). Settings-hook payloads carry
|
|
832
|
+
// the session's cwd and transcript path, which both follow it into a worktree
|
|
833
|
+
// (code.claude.com/docs/en/worktrees), so pass them on when they change.
|
|
834
|
+
function moved(e) {
|
|
835
|
+
const out = {}
|
|
836
|
+
const r = st.record || {}
|
|
837
|
+
if (e && typeof e.cwd === 'string' && e.cwd && e.cwd !== r.cwd) out.cwd = e.cwd
|
|
838
|
+
if (e && typeof e.transcript_path === 'string' && e.transcript_path && e.transcript_path !== r.transcriptPath) out.transcriptPath = e.transcript_path
|
|
839
|
+
if (out.cwd && st.hello) st.hello = { ...st.hello, cwd: out.cwd, repo: null } // a hello said again says where it is now
|
|
840
|
+
return out
|
|
841
|
+
}
|
|
842
|
+
|
|
831
843
|
export function register(on, options) {
|
|
832
844
|
on('session.start', async ($, e, next) => {
|
|
833
845
|
try {
|
|
@@ -890,7 +902,7 @@ export function register(on, options) {
|
|
|
890
902
|
})
|
|
891
903
|
|
|
892
904
|
on('classic.UserPromptSubmit', async ($, e, next) => {
|
|
893
|
-
await sendOrSpool($, 'activity', { kind: 'prompt', prompt: clip(e.prompt || '', 300), sessionTitle: e.session_title && e.session_title === st.tabTitle ? null : e.session_title }) // our own /rename isn't a name you chose
|
|
905
|
+
await sendOrSpool($, 'activity', { kind: 'prompt', prompt: clip(e.prompt || '', 300), sessionTitle: e.session_title && e.session_title === st.tabTitle ? null : e.session_title, ...moved(e) }) // our own /rename isn't a name you chose
|
|
894
906
|
return next(e)
|
|
895
907
|
})
|
|
896
908
|
|
|
@@ -920,6 +932,7 @@ export function register(on, options) {
|
|
|
920
932
|
// board's card offers "Claude wrote a 7-step list · Save". Nothing is saved here.
|
|
921
933
|
const listHint = listHintFrom(e.last_assistant_message || '', { fallback: st.daemonTitle || null })
|
|
922
934
|
await sendOrSpool($, 'turn_completed', {
|
|
935
|
+
...moved(e),
|
|
923
936
|
lastAssistantPreview: clip(e.last_assistant_message || '', 280),
|
|
924
937
|
question: trailingQuestion(e.last_assistant_message || ''),
|
|
925
938
|
...(listHint ? { listHint } : {}),
|
|
@@ -937,13 +950,18 @@ export function register(on, options) {
|
|
|
937
950
|
})
|
|
938
951
|
|
|
939
952
|
on('classic.Notification', async ($, e, next) => {
|
|
940
|
-
await sendOrSpool($, 'notification', { notificationType: e.notification_type || null, message: clip(e.message || '', 200) })
|
|
953
|
+
await sendOrSpool($, 'notification', { notificationType: e.notification_type || null, message: clip(e.message || '', 200), ...moved(e) })
|
|
941
954
|
return next(e)
|
|
942
955
|
})
|
|
943
956
|
|
|
944
957
|
on('classic.PermissionRequest', permissionHook)
|
|
945
958
|
|
|
946
|
-
on('classic.PostToolUse', async ($, e, next) => {
|
|
959
|
+
on('classic.PostToolUse', async ($, e, next) => {
|
|
960
|
+
await toolRan($, e)
|
|
961
|
+
const m = moved(e) // EnterWorktree, or a cd: say so now, not at the end of the turn
|
|
962
|
+
if (m.cwd || m.transcriptPath) await sendOrSpool($, 'activity', { kind: 'cwd', ...m })
|
|
963
|
+
return next(e)
|
|
964
|
+
})
|
|
947
965
|
|
|
948
966
|
on('classic.PostToolUseFailure', async ($, e, next) => { await toolRan($, e); return next(e) })
|
|
949
967
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
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
|
@@ -342,6 +342,9 @@ h2 .count { color: var(--faint); font-weight: 500; }
|
|
|
342
342
|
.repomore .opt[hidden] { display: none; }
|
|
343
343
|
.chip.repo-link { cursor: pointer; font: inherit; font-family: var(--mono); font-size: 11.5px; }
|
|
344
344
|
.chip.repo-link:hover { color: var(--text); border-color: var(--line-strong); }
|
|
345
|
+
/* 0.7.1: git worktree marker and "started in" */
|
|
346
|
+
.chip.wt { color: var(--text2); max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
347
|
+
.chip.started-in { color: var(--muted); border-style: dashed; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
345
348
|
.linkish { background: none; border: 0; padding: 0; color: var(--accent); cursor: pointer; font: inherit; }
|
|
346
349
|
.linkish:hover { text-decoration: underline; }
|
|
347
350
|
.digest-repos { margin: 0 0 14px; }
|
package/ui/app.js
CHANGED
|
@@ -139,11 +139,20 @@ function agentChip(a, { title } = {}) {
|
|
|
139
139
|
return '<span class="chip machine agent' + (a.ok === false ? ' stale' : '') + '" title="' + esc(title || ('external agent · ' + (a.pullsHere ? 'pulled by this Mac' : 'pulled by ' + (a.puller || 'another Mac')) + (a.lastSuccessAt ? ' · last pull ' + ago(a.lastSuccessAt) : ''))) + '">' + esc(a.icon || '◆') + ' ' + esc(a.display || a.name) + '</span>'
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
// 0.7.1: a session in a git worktree says which ("⑂ fix-login"); one filed
|
|
143
|
+
// where its edits went says where it started.
|
|
144
|
+
function wtChips(s) {
|
|
145
|
+
let h = ''
|
|
146
|
+
if (s.worktree && s.worktree.name) h += '<span class="chip mono wt" title="' + esc('git worktree · ' + (s.worktree.path || s.worktree.name)) + '">⑂ ' + esc(s.worktree.name) + '</span>'
|
|
147
|
+
if (s.startedIn) h += '<span class="chip started-in" title="' + esc('Started in ' + s.startedIn + '; its edits went to ' + (s.repoLabel || s.repoName || 'this repo')) + '">started in ' + esc(s.startedIn) + '</span>'
|
|
148
|
+
return h
|
|
149
|
+
}
|
|
150
|
+
|
|
142
151
|
function where(s) {
|
|
143
152
|
if (s.agent) return (s.project ? '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(s.repoLabel) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc(s.repoLabel + ' · show only this project') + '">' + esc(s.repoLabel) + '</button>' : '') + agentChip(s.agent)
|
|
144
153
|
const branch = s.repo && s.repo.branch
|
|
145
154
|
const label = s.repoLabel || s.repoName || 'no repo'
|
|
146
|
-
return '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' +
|
|
155
|
+
return '<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' + wtChips(s) +
|
|
147
156
|
(multi() || !s.isLocal ? '<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'synced from another machine') + '">⌂ ' + esc(s.machine) + '</span>' : '') + modelChip(s)
|
|
148
157
|
}
|
|
149
158
|
|
|
@@ -500,7 +509,7 @@ function closedCard(s, viewer) {
|
|
|
500
509
|
return '<article class="card ended is-closed closed-card" data-id="' + esc(s.id) + '" data-href="' + esc(href) + '">' +
|
|
501
510
|
'<div class="row1"><div class="title"><a href="' + href + '">' + esc(s.title || 'Untitled session') + '</a></div><span class="pill s-closed">closed</span></div>' +
|
|
502
511
|
'<div class="meta">' + (s.space && s.space !== s.machine ? spaceChip(s) : '') +
|
|
503
|
-
'<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' +
|
|
512
|
+
'<button type="button" class="chip mono repo-link" data-action="repo" data-repo="' + esc(label) + '" data-key="' + esc(s.repoKey || '') + '" title="' + esc((s.repoKey || label) + ' · show only this repo') + '">' + esc(label) + (branch ? ' · ' + esc(branch) : '') + '</button>' + wtChips(s) +
|
|
504
513
|
'<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'another machine') + '">⌂ ' + esc(s.machine) + '</span>' + modelChip(s) +
|
|
505
514
|
'<span title="' + esc(clock(s.endedAt)) + '">ended ' + esc(ago(s.endedAt)) + '</span><span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + '</span></div>' +
|
|
506
515
|
cardHandoffChips(s) + cpBlock(s.latestCheckpoint, { showWhen: false }) +
|
|
@@ -542,7 +551,7 @@ async function resumeOpen(btn, id) {
|
|
|
542
551
|
btn.disabled = true; btn.textContent = 'Opening…'
|
|
543
552
|
try {
|
|
544
553
|
const r = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/resume', {}, { timeoutMs: 60000 })
|
|
545
|
-
toast(r.peer ? 'Resumed on ' + r.peer + ' in a new Ghostty tab' : 'Resumed in a new Ghostty tab')
|
|
554
|
+
toast(r.note ? r.note : r.peer ? 'Resumed on ' + r.peer + ' in a new Ghostty tab' : 'Resumed in a new Ghostty tab', r.note ? 6000 : undefined)
|
|
546
555
|
return true
|
|
547
556
|
} catch (e) {
|
|
548
557
|
toast(e.message, 6000)
|
|
@@ -750,10 +759,10 @@ async function viewSession(id) {
|
|
|
750
759
|
const branch = s.repo && s.repo.branch
|
|
751
760
|
const spv = await api('GET', '/v1/spaces').catch(() => null)
|
|
752
761
|
let html = '<a class="back" href="#/">← All sessions</a>'
|
|
753
|
-
html += '<div class="pagehead"><div><h1>' + esc(s.title || 'Untitled session') + '</h1><div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + ' ' + esc(s.titleSource ? s.titleSource + ' title' : '') + '</div></div>' +
|
|
762
|
+
html += '<div class="pagehead"><div><h1>' + esc(s.title || 'Untitled session') + '</h1><div class="sub">' + pill(s.status) + ' ' + spaceChip(s, { force: true }) + wtChips(s) + ' ' + esc(s.titleSource ? s.titleSource + ' title' : '') + '</div></div>' +
|
|
754
763
|
'<div class="actions">' + goButton(s) + (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '') + '<a class="btn" href="#/timeline?session=' + encodeURIComponent(s.id) + '">' + icon('history') + 'Open in Timeline</a><button type="button" class="btn" data-action="resume" data-id="' + esc(s.id) + '">Copy resume</button>' + (s.isLocal ? '<button type="button" class="btn primary" data-action="checkpoint" data-id="' + esc(s.id) + '">Checkpoint now</button>' : '') + '</div></div>'
|
|
755
764
|
html += '<div class="facts">' +
|
|
756
|
-
fact('Machine', s.machine + (s.isLocal ? ' (this one)' : ' (synced)')) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
|
|
765
|
+
fact('Machine', s.machine + (s.isLocal ? ' (this one)' : ' (synced)')) + fact('Repo', (s.repoName || '—') + (branch ? ' · ' + branch : '')) + (s.worktree ? fact('Worktree', '⑂ ' + s.worktree.name + (s.worktree.branch ? ' · ' + s.worktree.branch : ''), 'wrap') : '') + (s.startedIn ? fact('Started in', s.startedIn) : '') + fact('Directory', s.cwd || '—') + fact('Last activity', ago(s.lastActivityAt)) + fact('Started', clock(s.startedAt)) +
|
|
757
766
|
fact('Turns', s.turns + ' (' + s.turnsSinceCheckpoint + ' since checkpoint)' + (s.historyTurns ? ' · resumed, ' + s.historyTurns + ' before' : '')) + fact('Capture', s.capture || '—') + fact('Model', s.modelLabel || s.model || '—', 'wrap') + fact('Session id', s.id) +
|
|
758
767
|
fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
|
|
759
768
|
fact('Terminal', (s.terminal ? s.terminal.label : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
|
package/ui/repos.js
CHANGED
|
@@ -77,7 +77,9 @@ export function repoMatches(s, token, { loose = false } = {}) {
|
|
|
77
77
|
if (cands.includes(tok)) return true
|
|
78
78
|
// "parent/base" given for a key that is ".../parent/base"
|
|
79
79
|
if (tok.includes('/') && String(key).toLowerCase().endsWith('/' + tok)) return true
|
|
80
|
-
|
|
80
|
+
if (loose && cands.some((c) => c.includes(tok))) return true
|
|
81
|
+
// 0.7.1: the other repos it belongs to (started in, edited in) find it too
|
|
82
|
+
return Array.isArray(s.alsoRepos) && s.alsoRepos.some((r) => r && r.key && repoMatches({ repoKey: r.key, repoLabel: r.label }, token, { loose }))
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
// The CLI's --repo: exact matches win; with none, a substring.
|