@leftoff/cli 0.9.8-beta.1 → 0.9.8-beta.11

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/docs/SECURITY.md CHANGED
@@ -102,7 +102,7 @@ Live link is opt-in, per session: only sessions started with `leftoff live` have
102
102
 
103
103
  - **The channel** is a stdio MCP server that Claude Code starts. It opens no port. It talks to the daemon on 127.0.0.1 with the board's token, and the daemon accepts its calls (hello, next message, ack, reply, permission) from loopback only. A channel is tied to the session whose process started it, so the daemon only writes into sessions on its own Mac. Other Macs go through the peer link.
104
104
  - **Prompts from the board or a phone** reach Claude exactly like typed input, so they follow the same rule as answering a permission: a phone needs *Trust my paired devices*, and they're rate-limited and written to the session's audit trail. Anyone who can send prompts can make Claude act, so treat trusting your paired devices as giving them your keyboard.
105
- - **Permission relay** never allows anything on its own. A prompt waits for Allow once or Deny. With no answer the terminal dialog stays and decides, and an answer in the terminal wins. Live link answers one call at a time and never adds session-wide rules.
105
+ - **Permission relay** never allows anything on its own. A prompt waits for Allow once or Deny. With no answer the terminal dialog stays and decides, and an answer in the terminal wins. Live link answers one call at a time and never adds session-wide rules. **Always allow here** (0.9.8) is a click that writes the exact rule its ⓘ tooltip and label show into that project's `.claude/settings.local.json` (the file Claude Code's own option writes), only for shapes an exact rule can pin down. A broken settings file is never overwritten.
106
106
  - **Outside events** need this Mac's event key (`~/.config/checkpoints/secrets/live-events`, mode 600, made by `leftoff live key`, replaced with `--new`). Locally that's a Bearer key. Through the relay only an HMAC signature is accepted (5-minute window, replays refused), so the relay can pass an event along but can't read the key, forge an event or change one. Both the relay and the Mac rate-limit, and bodies are capped at 16 KB. Claude's instructions call an event information, not a command, but it is still text Claude reads: anyone with the key can put words in front of your session. Keep the key in your CI's secrets.
107
107
  - **Remote Restore** opens Ghostty tabs, so a phone or another Mac can only ask. The Mac shows a notification and a card, and only that Mac's own browser can confirm. Requests expire after 5 minutes, at most 3 wait at once, and they're rate-limited.
108
108
 
@@ -111,6 +111,8 @@ Only Macs registered for that user can connect: the first with `leftoff relay re
111
111
 
112
112
  Every few seconds (`peers.pollMs`, default 4 s) each daemon asks every machine that accepts for `needs.list`, so another Mac's permission prompts and questions show on this board with a ⌂ machine chip. Answer, reply and Go on another machine's session go out as envelopes. When that isn't possible, the button is grey and says why: not allowed on that machine, unreachable, key changed, no key yet (that Mac needs 0.5.2), or no route. Answering from a phone keeps its own rules (Settings → Privacy & security), and Go stays a local-browser action.
113
113
 
114
+ Phone notifications (0.9.8) ride the same envelopes: `push.sync` carries the Web Push subscriptions a browser turned on (endpoint, keys, a device label) with the VAPID keys they were made with, plus 30-day removal tombstones; each side merges (newer word per endpoint wins) and answers with its own, so every Mac can push for its own sessions and only those. It is answered even when a Mac doesn't allow incoming commands, because it runs nothing there. Kept on each Mac in `~/.config/checkpoints/push/` (0600), never in the synced folder. Shared when a device subscribes or unsubscribes, at start, and every 20 minutes.
115
+
114
116
  ## Pairing through Syncthing (0.5.3)
115
117
 
116
118
  Before two Macs share a folder they have no channel to each other, so pairing rides on Syncthing itself. Leftoff never opens a port for it.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.9.8-beta.1",
3
+ "version": "0.9.8-beta.11",
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
@@ -299,9 +299,19 @@ 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
+ const args = [...(wt ? ['--worktree', wt] : []), '--resume', id]
303
+ return (cwd ? 'cd ' + shArg(cwd) + ' && ' : '') + (isCodex(session) ? codexInvocation(id) : wasLiveSession(session) ? liveInvocation(args) : claudeInvocation(session, args))
303
304
  }
304
305
 
306
+ // 0.9.8: a session started with `leftoff live` (it had Leftoff's Live channel)
307
+ // resumes as one, `leftoff live -- --resume <id>`, so it shows up Live again.
308
+ // Codex and Ollama sessions resume their own way (Live is Claude Code only).
309
+ export function wasLiveSession(session) {
310
+ if (!session || isCodex(session) || (session.backend && session.backend.kind === 'ollama')) return false
311
+ return !!(session.wasLive || (session.liveLink && session.liveLink.on))
312
+ }
313
+ export function liveInvocation(args = []) { return 'leftoff live --' + (args.length ? ' ' + args.map(shArg).join(' ') : '') }
314
+
305
315
  // 0.8.0: a Codex session (its rollout under ~/.codex/sessions) resumes with `codex resume <id>`.
306
316
  export function isCodex(session) { return !!session && session.codingAgent === 'codex' }
307
317
  export function codexInvocation(id) { return 'codex resume ' + shArg(id) }
@@ -547,7 +557,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
547
557
  export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
548
558
  'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
549
559
  'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
550
- 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink']
560
+ 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive', 'transcriptCwd', 'doubtedCheckpoints', 'liveLink', 'transcriptTurns', 'wasLive']
551
561
 
552
562
  export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
553
563
  'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
@@ -800,7 +810,7 @@ export function checkpointDue(s, now, cfg = DEFAULT_CONFIG) {
800
810
  export function isEmptySession(s, checkpointCount = 0) {
801
811
  if (!s) return true
802
812
  // 0.7.1: a prompt counts. A long first turn (a worktree bug fix) has no completed turn yet.
803
- return !(Number(s.turns) > 0) && !s.title && !s.firstPrompt && !(Number(checkpointCount) > 0) && !s.lastCheckpointId &&
813
+ return !(Number(s.turns) > 0) && !(Number(s.transcriptTurns) > 0) && !s.title && !s.firstPrompt && !(Number(checkpointCount) > 0) && !s.lastCheckpointId &&
804
814
  !(Number(s.historyTurns) > 0) && s.startSource !== 'resume'
805
815
  }
806
816
 
@@ -876,7 +886,7 @@ export function applyEvent(s, ev) {
876
886
  s.needsInput = false
877
887
  s.question = null
878
888
  s.lastNotification = null
879
- if (d.prompt && !s.firstPrompt) s.firstPrompt = clip(d.prompt, 300)
889
+ if (d.prompt && (!s.firstPrompt || /^\s*<channel\b/.test(s.firstPrompt))) s.firstPrompt = clip(String(d.prompt).replace(/^\s*<channel\b[^>]*>/, '').replace(/<\/channel>\s*$/, '').trim(), 300) || s.firstPrompt || null // 0.9.8: a Live link message, without its wrapper (even one the 300-character clip cut short)
880
890
  if (d.countTurn === true) s.turns = (s.turns || 0) + 1 // 0.8.1: Codex counts a turn at its prompt when no task_started came first
881
891
  if (s.capture === 'hooks') { s.inTurn = true; s.activeSince = s.activeSince || at }
882
892
  }
@@ -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.9.8-beta.1' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.9.8-beta.11' // keep in step with .claude-plugin/plugin.json
32
32
 
33
33
  const st = {
34
34
  internal: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.9.8-beta.1",
3
+ "version": "0.9.8-beta.11",
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",