@leftoff/cli 0.8.0 → 0.8.3

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.
@@ -0,0 +1,136 @@
1
+ # Leftoff peer protocol (v1)
2
+
3
+ How one Leftoff daemon asks another to **answer** a permission prompt or a question, **reply** to a session, or **Go** (bring its terminal tab to the front), without Tailscale and without trusting whatever carries the bytes. Shipped in 0.5.2; the hosted relay in 0.6.0. The code is `src/peer.js` (keys, cards, envelopes, the hub) and `src/relay.js` (the relay transport and a fake relay for tests).
4
+
5
+ ## Trust: the synced folder
6
+
7
+ Every daemon has a **machine identity**: an Ed25519 key pair that signs and an X25519 key pair that receives encrypted envelopes. The private halves stay on the Mac, at `~/.config/checkpoints/identity/<machine>.json` (`$XDG_CONFIG_HOME` if set), mode `0600`. That's next to the folder pointer, **not** in the synced folder, so the sync service (and every other Mac) never holds them.
8
+
9
+ The public halves are published in the synced folder as a **machine card**, `machines/<machine>.json`:
10
+
11
+ ```json
12
+ {
13
+ "v": 1, "app": "leftoff", "machine": "laptop", "hostname": "Your-MacBook-Air.local", "version": "0.5.2",
14
+ "sign": "<Ed25519 public key, base64url>", "box": "<X25519 public key, base64url>",
15
+ "fingerprint": "6923-89FD-4409-0E0F",
16
+ "accepts": true,
17
+ "addresses": [{ "url": "http://192.168.1.20:7821", "kind": "lan" }, { "url": "http://100.101.102.103:7821", "kind": "tailscale" }],
18
+ "relay": false,
19
+ "updatedAt": "2026-10-10T15:00:00.000Z",
20
+ "sig": "<Ed25519 signature by `sign` over the card without sig, canonical JSON>"
21
+ }
22
+ ```
23
+
24
+ - `fingerprint` is the first 64 bits of `sha256(sign ‖ box)`, shown in Settings → Privacy & security.
25
+ - `accepts` is the machine's opt-in, "Allow other machines to answer and Go here". It's **off by default**. While it's off, `addresses` is empty and nothing listens.
26
+ - A card is rewritten only when something other than `updatedAt` changes.
27
+
28
+ **Pinning.** The first time a daemon sees a machine's card it pins that fingerprint locally (in its own identity file). If the card later carries a different key, the machine shows **key changed** and every action to or from it is refused until you confirm the new key on this Mac (Settings → Privacy & security → Trust the new key, or `leftoff machines trust <machine>`). So a file dropped into the folder can't silently take over a machine name you already trust. Someone who can write the folder *before* the first sighting can still introduce a machine. The folder is the trust root, as the brief asked, so keep it in a sync service you trust.
29
+
30
+ ## Envelopes
31
+
32
+ Each command is one envelope. It's JSON and travels the same way over every transport:
33
+
34
+ | field | meaning |
35
+ |---|---|
36
+ | `v` | `1` |
37
+ | `kind` | `req`, or `res` for the answer |
38
+ | `from`, `to` | machine names |
39
+ | `id` | 16 random bytes, base64url: the nonce, the replay key and the HKDF salt |
40
+ | `iat`, `exp` | issued-at and expiry, epoch ms. `exp = iat + 60 s`; a lifetime over 120 s is refused |
41
+ | `re` | on a `res`: the `id` of the request it answers |
42
+ | `epk` | a fresh X25519 public key for this envelope only |
43
+ | `iv` | 12 bytes for AES-GCM |
44
+ | `ct` | AES-256-GCM ciphertext of the body, with its 16-byte tag appended |
45
+ | `sig` | Ed25519 signature by the sender's `sign` key over the canonical JSON of every field above (sorted keys, no `sig`) |
46
+
47
+ **Encryption.** `key = HKDF-SHA256(X25519(epk_private, recipient.box), salt = id, info = "leftoff-peer-v1|<from>|<to>")`. The AES-GCM additional data is the canonical JSON of the header (`v kind from to id iat exp re epk iv`). The ephemeral key gives each envelope its own key.
48
+
49
+ **Bodies.** A request is `{ "action": …, …args }`. A response is `{ "ok": true, "result": … }` or `{ "ok": false, "error": { "code", "message", "status" } }`.
50
+
51
+ | action | args | runs, on the receiving machine |
52
+ |---|---|---|
53
+ | `ping` | none | `{ machine, version, accepts }` (answered even when not accepting) |
54
+ | `needs.list` | none | that machine's own *Needs you* items (the same list its board shows) |
55
+ | `ask.answer` | `askId`, `body` | `engine.answerAsk()`: the board's Allow / Deny / answer code path |
56
+ | `session.reply` | `sessionId`, `text` | `engine.postReply()`: the board's reply box |
57
+ | `session.go` | `sessionId` | `engine.jump()`: the board's Go |
58
+
59
+ Answers are recorded in the session's `answers.md` as *from studio's board (direct)* or *(through the relay)*.
60
+
61
+ **Checks, in order, before anything runs:** the shape, `to` is this machine, the sender has a card in the folder, its key is pinned and unchanged, the signature, `exp` hasn't passed, `iat` is no more than 30 s in the future (clock skew), the `id` hasn't been seen (the replay cache keeps ids until they expire), and decryption. Then, unless the action is `ping`, this machine must accept (`not_allowed` otherwise). Refusal codes: `bad_envelope`, `wrong_target`, `unknown_sender`, `key_changed`, `bad_signature`, `expired`, `not_yet_valid`, `replayed`, `decrypt_failed`, `not_allowed`, `unknown_action`. When the sender is known and the envelope isn't forged or replayed, a refusal comes back as a sealed `res`. Otherwise it's a plain `{ "kind": "err", "code" }`.
62
+
63
+ ## Transports
64
+
65
+ The sender tries them in order and the first that answers wins. One request uses one fresh envelope per attempt.
66
+
67
+ 1. **Direct HTTP.** `POST <address>/peer/v1/envelope` with the envelope as the body, and the response envelope as the reply. Each accepting daemon listens on its own port (the board's port + 1 by default, `peers.port` in config). It binds `0.0.0.0` by default (`peers.bind`) and serves nothing else. Addresses are the Mac's LAN and Tailscale IPv4s, or `peers.addresses` if set. Tailscale isn't needed. It's just one more address when present.
68
+ 2. **Relay.** Both daemons hold one outbound WebSocket to `peers.relay` (`LEFTOFF_RELAY`) and the relay forwards envelopes it can't read. Neither Mac opens a port. With `peers.direct: false` a machine is relay-only and never listens.
69
+
70
+ ### Relay wire format
71
+
72
+ JSON text frames on a WebSocket:
73
+
74
+ ```
75
+ relay → client { "t": "challenge", "nonce" }
76
+ client → relay { "t": "hello", "machine", "sign", "nonce", "sig" } sig = Ed25519("leftoff-relay-hello|<machine>|<nonce>")
77
+ relay → client { "t": "welcome", "machine" } or { "t": "error", "code": "bad_hello" | "name_taken" }
78
+ client → relay { "t": "send", "to", "ref", "env" } ref = env.id
79
+ relay → client { "t": "deliver", "from", "ref", "env" } or { "t": "error", "ref", "code": "offline" }
80
+ ```
81
+
82
+ The relay learns which machines talk and when, and nothing else: bodies are end-to-end encrypted and signed. The fake relay (`FakeRelay` in `src/relay.js`, used by `test/sync-e2e.mjs`) pins name → key at the first hello. The client reconnects with backoff (1 s up to 30 s) and pings every 25 s.
83
+
84
+ ### The hosted relay (0.6.0)
85
+
86
+ `wss://<user>.leftoff.dev/_connect` (the Worker in `relay/`, [relay.md](relay.md)) speaks the same frames, plus accounts and the board tunnel:
87
+
88
+ ```
89
+ relay → client { "t": "challenge", "nonce", "user" }
90
+ client → relay { "t": "hello", "machine", "sign", "nonce", "sig", "version", "activeAt" }
91
+ sig = Ed25519("leftoff-relay-hello|<user>|<machine>|<nonce>") (the user is bound in)
92
+ relay → client { "t": "welcome", "machine", "user", "machines" } or { "t": "error", "code": "bad_hello" | "not_registered" | "key_mismatch" }
93
+ client → relay { "t": "ping" } → { "t": "pong" } (answered without waking the Durable Object; the round trip is the latency in Settings)
94
+ client → relay { "t": "machine.add", "id", "machine", "sign" } a registered Mac vouches for a Mac in its folder (its card has "anywhere": "<user>")
95
+ client → relay { "t": "activity", "at" } latest session activity, for the default Mac
96
+ client → relay { "t": "pair.create" | "pair.cancel", "id", "pair", "expiresAt" } → { "t": "ack", "id", "ok" }
97
+ client → relay { "t": "device.revoke", "id", "device" } · { "t": "devices.sync", "id", "revoked": [...] } · { "t": "devices.list", "id" }
98
+ relay → client { "t": "static", "id", "path" } → { "t": "static.res", "id", "status", "type", "b64" } (public app files)
99
+ relay → client { "t": "pair", "id", "pair", "pub", "name", "mac" } → { "t": "pair.res", "id", "ok", "device", "pub", "mac" }
100
+ relay → client { "t": "tunnel", "id", "env" } → { "t": "tunnel.res", "id", "env" } or { …, "error": { "code" } }
101
+ ```
102
+
103
+ Only Macs registered for that user can connect: the first with `leftoff relay register <user>` (the relay's admin token), the rest vouched for by a Mac already there. A tunnel `env` is `{ v: 1, d: device, to: machine, id, ts, iv, ct }`, AES-256-GCM under the device key with `leftoff-tunnel-v1|req|<d>|<to>|<id>|<ts>` as additional data; the answer is `{ v: 1, re: id, iv, ct }` with `leftoff-tunnel-v1|res|<d>|<to>|<id>`. The code is `ui/relay-crypto.js`, shared by the browser and the daemon.
104
+
105
+ ## What the board does with it
106
+
107
+ 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.
108
+
109
+ ## Pairing through Syncthing (0.5.3)
110
+
111
+ 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.
112
+
113
+ **Code** (`LO1-` + Crockford base32, read case-insensitively, dashes and spaces ignored):
114
+
115
+ | bytes | field |
116
+ |---|---|
117
+ | 1 | version (1) |
118
+ | 4 | expiry, Unix seconds, big-endian (15 minutes after it's made) |
119
+ | 16 | secret (random) |
120
+ | 35 | the first Mac's Syncthing device ID (its 56 base32 characters, decoded) |
121
+ | 1 + n | folder ID (`leftoff`) |
122
+ | 1 + n | the first Mac's machine name (up to 40 bytes, for "Waiting for studio…") |
123
+ | 4 | first 4 bytes of SHA-256 of everything above (catches typos) |
124
+
125
+ **Proof:** `tag = base32(HMAC-SHA256(secret, "leftoff-pair-v1\n" + joinerID + "\n" + firstID + "\n" + folderID))[0:16]`. IDs are normalized: upper case, no dashes. While it waits, the joining Mac's own Syncthing device name is `<name> (leftoff-pair <tag>)`.
126
+
127
+ **First Mac** (the daemon, every 1.5 s while a code is live; or `leftoff sync pair` without a daemon): it reads `GET /rest/cluster/pending/devices`. For each entry whose `name` ends in a tag, it recomputes the tag for **that entry's device ID** and compares in constant time. On a match it erases the secret first, then `PUT /rest/config/devices/<id>` (a new entry only) and adds the device to the `leftoff` folder's device list. Anything else is left pending.
128
+
129
+ **Joining Mac:** it adds the first Mac (`PUT /rest/config/devices/<id>`, `addresses: ["dynamic"]`), then creates or reuses the `leftoff` folder shared with it, with `.stignore`. It sets its own name with the tag and waits for `GET /rest/system/connections` to show the first Mac connected, then restores its name. It then watches `GET /rest/db/status?folder=leftoff` until the folder is idle, nothing is needed and `config.json` has arrived, and finally runs the join merge (`leftoff move`, or `leftoff setup` on a fresh Mac).
130
+
131
+ Refusals the joining Mac shows: `bad_code` (typo, cut short, not a Leftoff code), `expired`, `used` (already used on this Mac), `same_mac`, `nested` (the path is inside another Syncthing folder), `not_empty`, `needs_install`, and a timeout while waiting to be accepted ("is Leftoff running there?").
132
+
133
+ ## Not covered (yet)
134
+
135
+ - Key rotation other than "confirm the new key" (a Mac that's reinstalled gets a new key and shows as changed).
136
+ - Pushing changes: other machines' prompts arrive by polling, so they show up within a few seconds.
package/hooks/README.md CHANGED
@@ -19,4 +19,4 @@ To add them by hand, copy `settings-hooks.example.json` into your settings (with
19
19
 
20
20
  Hooks installed before 0.4.5 run `bin/checkpoints.js` and carry `--tag=checkpoints-hook`. They keep working (that file is now an alias of `bin/leftoff.js`), and `leftoff upgrade` replaces them with `--tag=leftoff-hook` ones, without duplicates.
21
21
 
22
- Without the mod you get no heartbeat, no `/cp` or `/cp-name`, and no pane. The daemon infers idle time from the last event and makes checkpoints itself. Use the web UI's "Checkpoint now" button for a manual one.
22
+ Without the mod you get no heartbeat, no `/cp`, `/cp-name` or `/cp-park`, and no pane. The daemon infers idle time from the last event and makes checkpoints itself. Use the web UI's "Checkpoint now" button for a manual one, and Park on a session's page (or `leftoff park [session] [note]`) to park one.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "leftoff",
3
- "version": "0.8.0",
3
+ "version": "0.8.3",
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
@@ -33,10 +33,11 @@ export const DEFAULT_CONFIG = {
33
33
  }
34
34
 
35
35
  export const STATUSES = ['active', 'waiting', 'background', 'needs_input', 'dormant', 'ended', 'stale']
36
- export const TRIGGERS = ['dormant', 'manual', 'session_end', 'clear', 'long_run']
36
+ export const TRIGGERS = ['dormant', 'manual', 'session_end', 'clear', 'long_run', 'park']
37
37
  export const EVENT_TYPES = [
38
38
  'session.opened', 'session.started', 'activity', 'turn_started', 'turn_completed',
39
39
  'notification', 'title_changed', 'heartbeat', 'session.ended',
40
+ 'session.parked', 'session.unparked', // 0.8.2: /cp-park and Park on the board
40
41
  ]
41
42
  export const NEEDS_INPUT_NOTIFICATIONS = ['permission_prompt', 'elicitation_dialog', 'agent_needs_input']
42
43
 
@@ -508,7 +509,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
508
509
  export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
509
510
  'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
510
511
  'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
511
- 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos']
512
+ 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked']
512
513
 
513
514
  export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
514
515
  'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
@@ -800,7 +801,17 @@ export function applyEvent(s, ev) {
800
801
  }
801
802
  if (d.term && typeof d.term === 'object') s.term = d.term
802
803
  else if (d.termEnv && typeof d.termEnv === 'object') s.term = detectTerminal(d.termEnv)
804
+ // 0.8.2: a parked session comes back by itself. Resumed (claude --resume, a
805
+ // reopened process, codex resume) or a new prompt after the park clears it;
806
+ // replayed older events (a Codex rollout read again) don't.
807
+ if (s.parked && ms(at) > ms(s.parked.at) && unparks(s, ev, d)) delete s.parked
803
808
  switch (ev.type) {
809
+ case 'session.parked':
810
+ 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 }
811
+ break
812
+ case 'session.unparked':
813
+ delete s.parked
814
+ break
804
815
  case 'session.opened':
805
816
  s.lastHeartbeatAt = at
806
817
  if (s.endedAt) { s.endedAt = null; s.endReason = null; s.closedAt = null } // reopened (claude --resume) in a new process
@@ -819,6 +830,7 @@ export function applyEvent(s, ev) {
819
830
  s.question = null
820
831
  s.lastNotification = null
821
832
  if (d.prompt && !s.firstPrompt) s.firstPrompt = clip(d.prompt, 300)
833
+ 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
822
834
  if (s.capture === 'hooks') { s.inTurn = true; s.activeSince = s.activeSince || at }
823
835
  }
824
836
  break
@@ -828,13 +840,14 @@ export function applyEvent(s, ev) {
828
840
  s.needsInput = false
829
841
  s.question = null
830
842
  s.activeSince = s.activeSince || at
843
+ if (d.countTurn === true) s.turns = (s.turns || 0) + 1 // 0.8.1: Codex counts a turn when it starts
831
844
  break
832
845
  case 'turn_completed':
833
846
  activity()
834
847
  s.inTurn = false
835
848
  s.activeSince = null
836
849
  s.needsInput = false
837
- s.turns = (s.turns || 0) + 1
850
+ if (d.countTurn !== false) s.turns = (s.turns || 0) + 1 // false: Codex already counted it at the start
838
851
  s.turnsSinceCheckpoint = (s.turnsSinceCheckpoint || 0) + 1
839
852
  s.backgroundTasks = Number(d.backgroundTasks || 0) + Number(d.sessionCrons || 0)
840
853
  if (d.lastAssistantPreview) s.lastPreview = clip(d.lastAssistantPreview, 280)
@@ -866,6 +879,23 @@ export function applyEvent(s, ev) {
866
879
  return s
867
880
  }
868
881
 
882
+ // 0.8.2: does this event bring a parked session back?
883
+ function unparks(s, ev, d) {
884
+ if (ev.type === 'session.opened') return !d.again // a new process (claude --resume); the mod's hello again is the same one
885
+ if (ev.type === 'session.started') return d.source === 'resume' && !!s.endedAt
886
+ if (ev.type === 'turn_started') return true
887
+ if (ev.type === 'activity') return d.kind === 'prompt'
888
+ return false
889
+ }
890
+
891
+ // /cp-park [note] -> { note } (quotes around the whole note are dropped)
892
+ export function parseParkArgs(args) {
893
+ let note = String(args == null ? '' : args).replace(/\s+/g, ' ').trim()
894
+ const q = note.match(/^(["'“‘])(.*)(["'”’])$/)
895
+ if (q) note = q[2].trim()
896
+ return { note: note ? clip(note, 300) : null }
897
+ }
898
+
869
899
  // The question Claude ended its turn on, if it ended on one: the last paragraph
870
900
  // of the answer when its last sentence ends with "?" (markdown and closing
871
901
  // quotes ignored). Code blocks don't count. -> string or null
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // It posts session lifecycle and activity to the local daemon
5
5
  // (http://127.0.0.1:7429), builds checkpoints with $.model.complete when the
6
- // daemon says one is due, adds /cp, /cp-name and /leftoff (and /checkpoints, its old name), and draws
6
+ // daemon says one is due, adds /cp, /cp-name, /cp-park and /leftoff (and /checkpoints, its old name), and draws
7
7
  // a pane listing every open session. When the daemon is down it spools events
8
8
  // to ~/checkpoints/.spool, keeps a session record in $.store, tracks idle
9
9
  // itself and writes checkpoint markdown straight into the folder.
@@ -18,7 +18,7 @@ import {
18
18
  sessionFolderName, id8, clip, toIso, resumeCommand, repoName,
19
19
  shortId, formatTabTitle, parseTabTitle, detectTerminal, parseProcLine, trailingQuestion,
20
20
  parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
21
- listHintFrom, extractChecklists, parseTodoArgs, parseDoneArgs, detectBackend,
21
+ listHintFrom, extractChecklists, parseTodoArgs, parseDoneArgs, detectBackend, parseParkArgs,
22
22
  } from './core.js'
23
23
 
24
24
  const PANE_ID = 'checkpoints'
@@ -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.8.0' // keep in step with .claude-plugin/plugin.json
30
+ const MOD_VERSION = '0.8.3' // keep in step with .claude-plugin/plugin.json
31
31
 
32
32
  const st = {
33
33
  internal: false,
@@ -202,6 +202,8 @@ const COMMANDS = [
202
202
  { key: 'catchup', names: ['cp-catchup', 'leftoff-catchup'], description: 'Leftoff: catch me up — what got done, where each session stopped, what\'s waiting on you', argumentHint: '[24h|3d|workday|friday] [repo…|here|all]', immediate: true },
203
203
  // v0.5.1: save the checklist Claude just wrote; tick its items.
204
204
  { key: 'todo', names: ['cp-todo', 'leftoff-todo'], description: 'Leftoff: save the checklist Claude just wrote as a list you can tick off (board, phone, or here)', argumentHint: '[name] [--pick [N]]', immediate: true },
205
+ // v0.8.2: checkpoint, mark the session Parked (not Closed when it exits), then close it.
206
+ { key: 'park', names: ['cp-park', 'leftoff-park'], description: 'Leftoff: checkpoint and park this session to come back to (your note becomes the Next line), then exit', argumentHint: '[note]', immediate: false },
205
207
  { key: 'done', names: ['cp-done', 'leftoff-done'], description: 'Leftoff: tick items on this session\'s list (/cp-done 3, /cp-done 2 4, /cp-done undo 3)', argumentHint: '<n…> [in <list>]', immediate: true },
206
208
  ]
207
209
 
@@ -254,6 +256,28 @@ async function checkpointCommand($, args) {
254
256
  return {}
255
257
  }
256
258
 
259
+ // ------------------------------------------------------------ park (v0.8.2)
260
+
261
+ // /cp-park [note]: a checkpoint now (the note, if any, is its Next line, else
262
+ // the usual summary), then the session is marked Parked, so it stays on the
263
+ // board under Parked instead of going to Closed when Claude Code exits. Then
264
+ // it runs the built-in /exit for you (registered with immediate: false, so it
265
+ // only runs once Claude is done with the turn). If that's refused, it says to
266
+ // type /exit. Nothing is ever killed.
267
+ const PARK_FALLBACK = 'Parked. Type /exit when you\'re ready, and it\'ll stay on your board.'
268
+ async function parkCommand($, args) {
269
+ if (st.internal || !st.sessionId) return { text: 'Leftoff: not in a tracked session.' }
270
+ const a = parseParkArgs(args)
271
+ const cp = await makeCheckpoint($, 'park', a.note, null, { next: a.note })
272
+ if (!cp) return { text: 'Leftoff: a checkpoint is already being made. Try /cp-park again in a moment.' }
273
+ await sendOrSpool($, 'session.parked', { note: a.note, checkpoint: cp.id || null, by: 'command' })
274
+ // After this command returns, not inside it: the built-in /exit.
275
+ $.clock.after(400, async () => {
276
+ try { await $.command.run({ command: 'exit' }) } catch { try { $.ui.toast(PARK_FALLBACK, { timeoutMs: 8000 }) } catch {} }
277
+ })
278
+ return { text: 'Parked' + (a.note ? ': ' + clip(a.note, 120) : '') + '. Closing this session; it stays on your board under Parked.' }
279
+ }
280
+
257
281
  // ------------------------------------------------------------ lists (v0.5.1)
258
282
 
259
283
  // This session's saved lists, when they changed since Claude last heard
@@ -568,7 +592,8 @@ async function writeCheckpointDirect($, cp) {
568
592
 
569
593
  // length: 'short' | 'informative' | 'long' for this checkpoint only (/cp --short,
570
594
  // /cp --long); null = config summary.length.
571
- async function makeCheckpoint($, trigger, userNote, length) {
595
+ // opts.next (0.8.2, /cp-park): the note is the checkpoint's Next line, word for word.
596
+ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
572
597
  if (st.internal || st.checkpointing || !st.sessionId) return null
573
598
  st.checkpointing = true
574
599
  try {
@@ -581,11 +606,12 @@ async function makeCheckpoint($, trigger, userNote, length) {
581
606
  let generator = reply ? { by: 'mod', model: reply.model, ...(reply.backend ? { backend: reply.backend } : {}) } : null
582
607
  if (!parsed) {
583
608
  // No model answer: let the daemon try its own summarizer, if it's there.
584
- const r = await api($, 'POST', '/v1/sessions/' + encodeURIComponent(st.sessionId) + '/checkpoints', { trigger, generate: true, userNote: userNote || undefined, length: ctx.length }, MODEL_TIMEOUT_MS * 3)
609
+ const r = await api($, 'POST', '/v1/sessions/' + encodeURIComponent(st.sessionId) + '/checkpoints', { trigger, generate: true, userNote: userNote || undefined, length: ctx.length, next: opts.next || undefined }, MODEL_TIMEOUT_MS * 3)
585
610
  if (r && r.ok && r.json) return afterCheckpoint($, r.json, trigger)
586
611
  parsed = heuristicCheckpoint(messages, record, { length: ctx.length })
587
612
  generator = { by: 'heuristic' }
588
613
  }
614
+ if (opts.next) parsed = { ...parsed, next: opts.next }
589
615
  const cp = {
590
616
  sessionId: st.sessionId, createdAt: nowIso(), trigger, machine: st.machine,
591
617
  sessionTitle: record.title || null, repo: record.repo ? repoName(record) : null, branch: (record.repo && record.repo.branch) || null, cwd: record.cwd || null,
@@ -988,6 +1014,8 @@ export function register(on, options) {
988
1014
  on('command.run', { command: 'cp-done' }, async ($, e) => tickTodo($, e.args))
989
1015
  on('command.run', { command: 'leftoff-done' }, async ($, e) => tickTodo($, e.args))
990
1016
  on('command.run', { command: 'leftoff-catchup' }, async ($, e) => makeCatchup($, e.args))
1017
+ on('command.run', { command: 'cp-park' }, async ($, e) => parkCommand($, e.args))
1018
+ on('command.run', { command: 'leftoff-park' }, async ($, e) => parkCommand($, e.args))
991
1019
 
992
1020
  on('command.run', { command: 'checkpoints' }, async ($, e) => paneCommand($, e.args))
993
1021
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.8.0",
3
+ "version": "0.8.3",
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",
@@ -28,6 +28,7 @@
28
28
  "assets",
29
29
  "templates",
30
30
  "hooks",
31
+ "docs",
31
32
  "CHANGELOG.md",
32
33
  "README.md",
33
34
  "LICENSE.md"
package/ui/app.css CHANGED
@@ -1478,6 +1478,24 @@ li.todo.ticked .todo-text { text-decoration: line-through; }
1478
1478
  .todo-add { display: flex; gap: 8px; margin-top: 10px; }
1479
1479
  .todo-add input { flex: 1; min-width: 0; font-size: 16px; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--field); color: var(--text); }
1480
1480
  .todo-add input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); }
1481
+ /* 0.8.2: Parked */
1482
+ .s-parked { color: var(--accent); background: var(--tint-accent); }
1483
+ .parked-sec { margin-top: 22px; }
1484
+ .parked-h { font-size: 13px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted); margin: 0 0 8px; display: flex; align-items: center; gap: 8px; }
1485
+ .parked-h .count { font-size: 12px; color: var(--faint); letter-spacing: 0; }
1486
+ .parked-card { border-style: dashed; }
1487
+ .parked-card .row1 .title a { color: var(--text); }
1488
+ .park-note { font-size: 13.5px; color: var(--text); background: var(--subtle); border: 1px solid var(--line); border-radius: 10px; padding: 8px 11px; overflow-wrap: anywhere; }
1489
+ .park-note .k { color: var(--faint); font-size: 11.5px; text-transform: uppercase; letter-spacing: .6px; margin-right: 6px; }
1490
+ .parked-card .park-done { margin-left: auto; color: var(--muted); font-size: 13px; padding: 6px 4px; }
1491
+ .park-row { margin: 14px 0 4px; }
1492
+ .park-row.is-on { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: space-between; border: 1px dashed var(--line-strong); border-radius: 10px; padding: 8px 12px; }
1493
+ .park-row .park-on { min-width: 0; overflow-wrap: anywhere; }
1494
+ .park-note-inline { color: var(--text); }
1495
+ .park-form { display: flex; gap: 8px; }
1496
+ .park-form input { flex: 1; min-width: 0; font-size: 16px; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--field); color: var(--text); }
1497
+ .park-form input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); }
1498
+ .park-hint { font-size: 12.5px; margin-top: 6px; }
1481
1499
  .handoff-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 2px; }
1482
1500
  .handoff-chips .chip { text-decoration: none; }
1483
1501
  .chip.todochip { color: var(--yellow); border-color: color-mix(in srgb, var(--yellow) 35%, transparent); background: var(--tint-yellow); }
@@ -1516,6 +1534,7 @@ html.dv-open, html.dv-open body { overflow: hidden; }
1516
1534
  .doc-md blockquote { margin: 10px 0; padding: 2px 12px; border-left: 3px solid var(--line-strong); color: var(--muted); }
1517
1535
  .doc-md li.sub { margin-left: 18px; }
1518
1536
  .doc-md li.task { list-style: none; margin-left: -18px; }
1537
+ .doc-page { max-width: 760px; padding-bottom: 32px; } /* 0.8.1: #/docs/<name> */
1519
1538
  .doc-md .tbox { display: inline-block; width: 13px; height: 13px; border: 1.5px solid var(--line-strong); border-radius: 3px; margin-right: 7px; vertical-align: -1px; }
1520
1539
  .doc-md .tbox.on { background: var(--green); border-color: var(--green); }
1521
1540
  .doc-table { overflow-x: auto; }
@@ -1525,3 +1544,26 @@ html.dv-open, html.dv-open body { overflow: hidden; }
1525
1544
  .doc-md th, .doc-md td { border: 1px solid var(--line); padding: 5px 8px; text-align: left; }
1526
1545
  .doc-img { text-align: center; padding-top: 10px; }
1527
1546
  .doc-img img { max-width: 100%; height: auto; border-radius: 8px; background: #fff; }
1547
+
1548
+ /* 0.8.3: fix lines, low disk, sync chip, "Set up this Mac too" */
1549
+ .fixline { font-family: var(--mono); font-size: 12px; color: var(--text3, var(--text2)); opacity: .85; margin-top: 3px; overflow-wrap: anywhere; }
1550
+ .stpeer .fixline { flex-basis: 100%; margin-top: 0; }
1551
+ .ststatus .fixline { flex-basis: 100%; }
1552
+ .banner.needs-style { border-color: color-mix(in srgb, var(--red) 45%, var(--line)); background: linear-gradient(180deg, var(--tint-red), transparent 80%), var(--card); }
1553
+ .banner.needs-style b { color: var(--red); }
1554
+ .banner .grow { min-width: 0; }
1555
+ .banner.setup-mac .joinline { margin-top: 6px; }
1556
+ .banner.setup-mac code.cmd { overflow-wrap: anywhere; }
1557
+ .pagehead .syncchip { margin-top: 8px; }
1558
+ .chip.syncchip { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; font: inherit; font-size: 12.5px; padding: 3px 10px; border-radius: 999px; border: 1px solid var(--line); background: var(--card); color: var(--text2); }
1559
+ .chip.syncchip .sdot { width: 8px; height: 8px; border-radius: 50%; background: var(--yellow); flex: none; }
1560
+ .chip.syncchip.warn { border-color: color-mix(in srgb, var(--yellow) 45%, transparent); background: var(--tint-yellow); color: var(--text); }
1561
+ .chip.syncchip.red { border-color: color-mix(in srgb, var(--red) 45%, transparent); background: var(--tint-red); color: var(--text); }
1562
+ .chip.syncchip.red .sdot { background: var(--red); }
1563
+ .syncdetails { margin: 0 0 14px; padding: 10px 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); font-size: 13px; display: flex; flex-direction: column; gap: 8px; }
1564
+ .syncitem.red b { color: var(--red); }
1565
+ .stale-line { font-size: 12.5px; margin: -6px 0 12px; }
1566
+ .chip.machine.stale { border-style: dashed; }
1567
+ .rchip .rx { margin-left: 4px; font-size: 11px; opacity: .7; }
1568
+ .syncdetails .joinline { margin-top: 4px; }
1569
+ .syncdetails code.cmd { font-family: var(--mono); font-size: 12px; background: var(--bg2); border: 1px solid var(--line); border-radius: 6px; padding: 3px 8px; overflow-wrap: anywhere; min-width: 0; }