@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/CHANGELOG.md +37 -0
- package/bin/leftoff.js +393 -373
- package/docs/SECURITY.md +1 -1
- package/docs/peer-protocol.md +2 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +14 -4
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +554 -15
- package/ui/app.js +1221 -53
- package/ui/attach.js +211 -0
- package/ui/composer.js +46 -0
- package/ui/index.html +18 -6
- package/ui/lightbox.js +452 -0
- package/ui/live-status.js +97 -0
- package/ui/live-view.js +387 -0
- package/ui/live-wake.js +96 -0
- package/ui/nav.js +88 -0
- package/ui/navhide.js +35 -0
- package/ui/needs.js +32 -1
- package/ui/pdfview.js +510 -0
- package/ui/pull.js +31 -0
- package/ui/push.js +140 -0
- package/ui/relay-client.js +70 -5
- package/ui/sw.js +66 -6
- package/ui/tabs.js +216 -0
- package/ui/tips.js +117 -0
- package/ui/vendor/PDFJS-LICENSE.txt +179 -0
- package/ui/vendor/pdfjs/pdf.min.js +27 -0
- package/ui/vendor/pdfjs/pdf.worker.min.js +27 -0
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
|
|
package/docs/peer-protocol.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
}
|
package/mod/hooks/register.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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",
|