@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.7.0",
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.cwd
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
- return !(Number(s.turns) > 0) && !s.title && !(Number(checkpointCount) > 0) && !s.lastCheckpointId &&
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
 
@@ -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.0' // keep in step with .claude-plugin/plugin.json
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) => { await toolRan($, e); return next(e) })
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.0",
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 }) + ' &nbsp;' + 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) + ' &nbsp;' + 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
- return loose && cands.some((c) => c.includes(tok))
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.