@leftoff/cli 0.8.5 → 0.8.6

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.8.5",
3
+ "version": "0.8.6",
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
@@ -55,7 +55,19 @@ export function mergeConfig(base, over) {
55
55
  return out
56
56
  }
57
57
 
58
+ // A time-ordered UUIDv7 (Codex rollouts, some Claude Code ids) starts with
59
+ // the clock: every session in the same few hours shared "01a133" (0.8.6).
60
+ // Those take their short forms from the random tail instead; UUIDv4 ids keep
61
+ // the head, so existing tab markers and folders still match.
62
+ const UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
63
+ export function isTimeOrderedId(id) { return UUID_V7.test(String(id || '')) }
58
64
  export function id8(id) {
65
+ const s = String(id || '')
66
+ if (isTimeOrderedId(s)) return s.replace(/-/g, '').slice(-8).toLowerCase()
67
+ return s.replace(/[^A-Za-z0-9]/g, '').slice(0, 8).toLowerCase() || 'unknown0'
68
+ }
69
+ // What id8 was before 0.8.6 (folders made then are still found by it).
70
+ export function legacyId8(id) {
59
71
  return String(id || '').replace(/[^A-Za-z0-9]/g, '').slice(0, 8).toLowerCase() || 'unknown0'
60
72
  }
61
73
 
@@ -509,7 +521,7 @@ const SESSION_ORDER = ['id', 'title', 'title_source', 'machine', 'cwd', 'repo',
509
521
  export const SESSION_EXTRA = ['inTurn', 'needsInput', 'backgroundTasks', 'turnsSinceCheckpoint', 'dormantStretch',
510
522
  'finalCheckpointDone', 'firstPrompt', 'lastPreview', 'lastNotification', 'lastEventAt', 'statusChanges', 'folder',
511
523
  'pid', 'tty', 'pidStartedAt', 'pidSource', 'term', 'closedAt', 'answers', 'lastNotificationMessage', 'needsInputAt', 'question', 'questionAt',
512
- 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked']
524
+ 'startSource', 'historyTurns', 'healedAt', 'healedVia', 'archivedReason', 'backend', 'prLinks', 'prUnlinked', 'todos', 'files', 'startRepo', 'editedRepos', 'parked', 'nonInteractive']
513
525
 
514
526
  export const SESSION_FIELDS = ['id', 'title', 'titleSource', 'machine', 'cwd', 'repo', 'transcriptPath', 'model', 'surface', 'capture',
515
527
  'status', 'startedAt', 'lastActivityAt', 'lastHeartbeatAt', 'endedAt', 'endReason', 'turns', 'lastCheckpointId',
@@ -572,7 +584,7 @@ export function parseSessionMd(text) {
572
584
  // sets the *session's* name with Claude Code's own /rename and lets Claude Code
573
585
  // draw it; a raw OSC 2 write would be painted over within a second.
574
586
 
575
- export function shortId(id) { return id8(id).slice(0, 6) }
587
+ export function shortId(id) { const k = id8(id); return isTimeOrderedId(id) ? k.slice(-6) : k.slice(0, 6) }
576
588
  export function tabMarker(id) { return 'cp:' + shortId(id) }
577
589
 
578
590
  const MARKER_RE = /\s*(?:·|-|\|)?\s*cp:([a-z0-9]{6})\s*$/i
@@ -792,6 +804,9 @@ export function applyEvent(s, ev) {
792
804
  if (d.repo && typeof d.repo === 'object') s.repo = { ...(s.repo || {}), ...stripNulls(d.repo) }
793
805
  // A title carrying our own tab marker came from the mod's /rename: it is the
794
806
  // tab label, not a name the user chose, so it never overwrites the title.
807
+ // 0.8.6: Claude Code's own generated title (the status line hears it as
808
+ // session_name). It names the session unless you named it yourself.
809
+ if (typeof d.generatedTitle === 'string' && d.generatedTitle.trim() && !parseTabTitle(d.generatedTitle).id && !isOwnTabTitle(d.generatedTitle, s) && !['custom', 'manual'].includes(s.titleSource) && d.generatedTitle.trim() !== s.title) { s.title = clip(d.generatedTitle.trim(), 120); s.titleSource = 'claude' }
795
810
  if (d.sessionTitle && !parseTabTitle(d.sessionTitle).id && d.sessionTitle !== s.title && !isOwnTabTitle(d.sessionTitle, s)) { s.title = d.sessionTitle; s.titleSource = 'custom' }
796
811
  if (d.proc && typeof d.proc === 'object' && Number(d.proc.pid) > 1) {
797
812
  s.pid = Number(d.proc.pid)
@@ -823,6 +838,7 @@ export function applyEvent(s, ev) {
823
838
  if (s.capture === 'mod') s.lastHeartbeatAt = at
824
839
  break
825
840
  case 'activity':
841
+ if (d.kind === 'name') break // 0.8.6: a title heard by the status line is not activity
826
842
  activity()
827
843
  if (d.kind === 'tool' && d.phase === 'post' && s.needsInput && (s.lastNotification === 'permission_prompt' || s.capture === 'codex')) { s.needsInput = false; if (s.capture === 'codex') s.lastNotification = null }
828
844
  if (d.kind === 'prompt') {
@@ -1559,3 +1575,28 @@ export function parseDoneArgs(args) {
1559
1575
  if (nums.length && nums.every((x) => /^#?\d+$/.test(x))) return { numbers: nums.map((x) => Number(x.replace('#', ''))), text: null, list: inList ? inList[1] : null, undo }
1560
1576
  return { numbers: [], text: main.trim() || null, list: inList ? inList[1] : null, undo }
1561
1577
  }
1578
+
1579
+ // 0.8.6: /cp prints where this session's card opens (GET /v1/sessions/<id>/links).
1580
+ export function boardLinkLines(l) {
1581
+ if (!l || !l.local) return []
1582
+ const out = ['Board: ' + l.local]
1583
+ if (l.relay) out.push('Phone: ' + l.relay)
1584
+ for (const u of l.tailnet || []) out.push('Tailnet: ' + u)
1585
+ return out
1586
+ }
1587
+
1588
+ // 0.8.6: runs nobody sits in front of: `claude -p` / the Agent SDK
1589
+ // (CLAUDE_CODE_ENTRYPOINT sdk-*, or a mod session with no surface) and
1590
+ // `codex exec`. They aren't your work sessions, so the board leaves them out.
1591
+ export function isNonInteractive({ surface = null, entrypoint = null } = {}) {
1592
+ const e = String(entrypoint || '').toLowerCase()
1593
+ if (/^sdk/.test(e) || e === 'print' || e === 'headless') return true
1594
+ return surface === 'headless' || surface === 'codex-exec'
1595
+ }
1596
+
1597
+ // Our own summarizer's and titler's prompts (buildCheckpointPrompt, TITLE
1598
+ // prompt): a session that starts with one is Leftoff talking to a model.
1599
+ export function isOwnSummarizerPrompt(p) {
1600
+ const t = String(p || '')
1601
+ return /^First prompt:\n/.test(t) || /^Repo: .* · cwd .*\nTitle: .*\nPrevious checkpoint: /.test(t)
1602
+ }
@@ -19,6 +19,7 @@ import {
19
19
  shortId, formatTabTitle, parseTabTitle, detectTerminal, parseProcLine, trailingQuestion,
20
20
  parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
21
21
  listHintFrom, extractChecklists, parseTodoArgs, parseDoneArgs, detectBackend, parseParkArgs,
22
+ boardLinkLines, isNonInteractive,
22
23
  } from './core.js'
23
24
 
24
25
  const PANE_ID = 'checkpoints'
@@ -27,7 +28,7 @@ const END_TIMEOUT_MS = 1000
27
28
  const MODEL_TIMEOUT_MS = 20000
28
29
 
29
30
  // Module state. $.state is not used: the pane redraws with $.ui.invalidate.
30
- const MOD_VERSION = '0.8.5' // keep in step with .claude-plugin/plugin.json
31
+ const MOD_VERSION = '0.8.6' // keep in step with .claude-plugin/plugin.json
31
32
 
32
33
  const st = {
33
34
  internal: false,
@@ -137,7 +138,9 @@ async function sayHelloAgain($) {
137
138
 
138
139
  async function isInternal($) {
139
140
  // Leftoff's own claude runs (the summarizer's claude -p, claude plugin …) carry this.
140
- return !!((await $.env.get('LEFTOFF_INTERNAL')) || (await $.env.get('CHECKPOINTS_INTERNAL')))
141
+ if ((await $.env.get('LEFTOFF_INTERNAL')) || (await $.env.get('CHECKPOINTS_INTERNAL'))) return true
142
+ // 0.8.6: `claude -p` / the Agent SDK (CLAUDE_CODE_ENTRYPOINT sdk-*): not a work session.
143
+ try { return isNonInteractive({ entrypoint: await $.env.get('CLAUDE_CODE_ENTRYPOINT') }) } catch { return false }
141
144
  }
142
145
 
143
146
  async function loadSetup($) {
@@ -252,8 +255,18 @@ async function reportModHealth($) {
252
255
  async function checkpointCommand($, args) {
253
256
  const a = parseCheckpointArgs(args)
254
257
  const cp = await makeCheckpoint($, 'manual', a.note, a.length)
255
- if (!cp) return { text: 'Leftoff: a checkpoint is already being made.' }
256
- return {}
258
+ const links = await boardLinks($)
259
+ if (!cp) return { text: ['Leftoff: a checkpoint is already being made.', ...links].join('\n') }
260
+ return links.length ? { text: links.join('\n') } : {}
261
+ }
262
+
263
+ // 0.8.6: this session's card on the board, here and (when they're on) from
264
+ // your phone (relay, tailnet); the daemon knows those. boardLinkLines: core.js.
265
+ async function boardLinks($) {
266
+ if (st.internal || !st.sessionId) return []
267
+ const r = await api($, 'GET', '/v1/sessions/' + encodeURIComponent(st.sessionId) + '/links', undefined, 800)
268
+ if (r && r.ok && r.json) return boardLinkLines(r.json)
269
+ return boardLinkLines({ local: 'http://127.0.0.1:' + (st.port || 7429) + '/#/s/' + encodeURIComponent(st.sessionId) })
257
270
  }
258
271
 
259
272
  // ------------------------------------------------------------ park (v0.8.2)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leftoff/cli",
3
- "version": "0.8.5",
3
+ "version": "0.8.6",
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
@@ -1605,3 +1605,28 @@ html.dv-open, html.dv-open body { overflow: hidden; }
1605
1605
  .rchip .rx { margin-left: 4px; font-size: 11px; opacity: .7; }
1606
1606
  .syncdetails .joinline { margin-top: 4px; }
1607
1607
  .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; }
1608
+
1609
+ /* 0.8.6: board search */
1610
+ .board-search-row { margin: 12px 0 0; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
1611
+ .board-search { flex: 1 1 280px; max-width: 420px; }
1612
+ .board-search-note { color: var(--muted); font-size: 13px; }
1613
+ @media (max-width: 640px) { .board-search { max-width: none; flex-basis: 100%; } }
1614
+
1615
+ /* 0.8.6: machine emojis */
1616
+ .chip.machine { cursor: pointer; font: inherit; }
1617
+ .chip.machine .memo { font-size: 1.05em; line-height: 1; }
1618
+ .emoji-sheet-wrap { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center; }
1619
+ .emoji-backdrop { position: absolute; inset: 0; background: rgba(0, 0, 0, .35); }
1620
+ .emoji-sheet { position: relative; width: min(380px, calc(100vw - 24px)); background: var(--card, var(--bg)); color: var(--text); border: 1px solid var(--line); border-radius: 14px; padding: 14px; box-shadow: 0 12px 40px rgba(0, 0, 0, .25); }
1621
+ .emoji-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; font-size: 15px; }
1622
+ .emoji-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); gap: 6px; }
1623
+ .emoji-opt { height: 44px; font-size: 24px; line-height: 1; border: 1px solid var(--line); border-radius: 10px; background: var(--field); cursor: pointer; }
1624
+ .emoji-opt:hover { border-color: var(--line-strong); }
1625
+ .emoji-opt.on { border-color: var(--accent); box-shadow: 0 0 0 3px var(--focus); }
1626
+ .emoji-own { display: flex; gap: 8px; margin-top: 12px; }
1627
+ .emoji-own input { flex: 1; min-width: 0; height: 40px; padding: 0 10px; font: inherit; font-size: 16px; background: var(--field); color: var(--text); border: 1px solid var(--line); border-radius: 10px; }
1628
+ .emoji-foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 10px; font-size: 12.5px; flex-wrap: wrap; }
1629
+ @media (max-width: 599.98px) {
1630
+ .emoji-sheet-wrap { align-items: flex-end; }
1631
+ .emoji-sheet { width: 100vw; border-radius: 16px 16px 0 0; padding-bottom: calc(14px + env(safe-area-inset-bottom)); }
1632
+ }
package/ui/app.js CHANGED
@@ -4,12 +4,13 @@ import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
4
4
  import { icon } from './icons.js'
5
5
  import { selectHtml, comboHtml, closeMenu } from './picker.js'
6
6
  import { SETTINGS, settingsKeyFor } from './settings-nav.js'
7
- import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst } from './repos.js'
7
+ import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches } from './repos.js'
8
8
  import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
9
9
  import { SUMMARY_EXAMPLES, LENGTH_CHOICES } from './summary-examples.js'
10
10
  import { mdInline, renderSection } from './changelog-md.js'
11
11
  import { cardPrsHtml, sessionPrsSection, prsPageHtml, prsBodyHtml, prRowHtml, prRows, readPrFilter, prsHash } from './prs.js'
12
- import { wantsSetupBanner, setupCandidate, localLeftoffAnswers } from './viewer.js'
12
+ import { wantsSetupBanner, setupCandidate, localLeftoffAnswers, thisOneSuffix } from './viewer.js'
13
+ import { defaultEmoji, PICKER_SET, isEmoji } from './machines.js'
13
14
  import { todosSection, sessionTodosSection, cardHandoffChips, sessionFilesSection, docViewerBody, docDownloadHref } from './handoffs.js'
14
15
  const $app = document.getElementById('app')
15
16
  const state = { sessions: [], health: null, cfg: null, route: null, machine: localStorage.getItem('cp.machine') || '', drafts: {}, pendingAsk: {}, mayAnswer: true, needOpen: {}, needTurns: {} }
@@ -159,7 +160,7 @@ function where(s) {
159
160
  const branch = s.repo && s.repo.branch
160
161
  const label = s.repoLabel || s.repoName || 'no repo'
161
162
  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) +
162
- (multi() || !s.isLocal ? (() => { const sn = s.isLocal ? '' : staleNote(s.machine); return '<span class="chip machine' + (s.isLocal ? '' : ' remote') + (sn ? ' stale' : '') + '" title="' + (s.isLocal ? 'this machine' : sn ? 'showing data as of ' + esc(sn.replace(/^as of /, '')) + ': ' + esc(s.machine) + ' hasn’t synced since' : 'synced from another machine') + '">⌂ ' + esc(s.machine) + (sn ? ' · ' + esc(sn) : '') + '</span>' })() : '') + modelChip(s)
163
+ (multi() || !s.isLocal ? (() => { const sn = s.isLocal ? '' : staleNote(s.machine); return '<button type="button" class="chip machine' + (s.isLocal ? '' : ' remote') + (sn ? ' stale' : '') + '" title="' + (s.isLocal ? 'this machine' : sn ? 'showing data as of ' + esc(sn.replace(/^as of /, '')) + ': ' + esc(s.machine) + ' hasn’t synced since' : 'synced from another machine') + '" data-action="machine-emoji" data-machine="' + esc(s.machine) + '"><span class="memo">' + esc(mEmoji(s.machine)) + '</span> ' + esc(s.machine) + (sn ? ' · ' + esc(sn) : '') + '</button>' })() : '') + modelChip(s)
163
164
  }
164
165
 
165
166
  // 0.6.8: a session that ran through Ollama (or another server) says so on its card.
@@ -232,6 +233,38 @@ function setupDismissed() { try { return window.localStorage.getItem(SETUP_KEY)
232
233
 
233
234
  // The board's sync chip: quiet when all's well, amber or red when Syncthing
234
235
  // reports an error, a disk runs low, or another Mac has been away 15+ min.
236
+ // 0.8.6: each Mac's emoji (the daemon sends the map; a new name gets its default here).
237
+ function mEmoji(m) {
238
+ const map = (state.health && state.health.machineEmojis) || {}
239
+ return map[m] || defaultEmoji(m, new Set(Object.values(map)))
240
+ }
241
+ // Tap a Mac's chip: our own emoji picker (a sheet on a phone), never a native control.
242
+ function openEmojiSheet(machine) {
243
+ closeEmojiSheet()
244
+ const cur = mEmoji(machine)
245
+ const el = document.createElement('div')
246
+ el.className = 'emoji-sheet-wrap'
247
+ el.innerHTML = '<div class="emoji-backdrop" data-action="emoji-close"></div><div class="emoji-sheet" role="dialog" aria-modal="true" aria-label="Emoji for ' + esc(machine) + '">' +
248
+ '<div class="emoji-head"><b>' + esc(cur) + ' ' + esc(machine) + '</b><button type="button" class="linkish" data-action="emoji-close" aria-label="Close">✕</button></div>' +
249
+ '<div class="emoji-grid">' + PICKER_SET.map((e) => '<button type="button" class="emoji-opt' + (e === cur ? ' on' : '') + '" data-action="emoji-pick" data-machine="' + esc(machine) + '" data-emoji="' + esc(e) + '" aria-label="' + esc(e) + '">' + esc(e) + '</button>').join('') + '</div>' +
250
+ '<div class="emoji-own"><input id="emojiOwn" type="text" inputmode="text" maxlength="16" placeholder="or type any emoji" aria-label="Any emoji" autocomplete="off" autocapitalize="off" spellcheck="false">' +
251
+ '<button type="button" class="btn primary" data-action="emoji-own" data-machine="' + esc(machine) + '">Use</button></div>' +
252
+ '<div class="emoji-foot"><button type="button" class="linkish" data-action="emoji-pick" data-machine="' + esc(machine) + '" data-emoji="">Back to the default</button><span class="faint">Every Mac and your phone show the same one.</span></div></div>'
253
+ document.body.appendChild(el)
254
+ const first = el.querySelector('.emoji-opt.on') || el.querySelector('.emoji-opt')
255
+ if (first) first.focus()
256
+ el.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') closeEmojiSheet() })
257
+ }
258
+ function closeEmojiSheet() { document.querySelectorAll('.emoji-sheet-wrap').forEach((x) => x.remove()) }
259
+ async function setMachineEmoji(machine, emoji) {
260
+ try {
261
+ const r = await api('POST', '/v1/machines/emoji', { machine, emoji: emoji || null })
262
+ if (state.health) state.health.machineEmojis = r.machineEmojis
263
+ closeEmojiSheet()
264
+ render()
265
+ } catch (e) { toast(e.message, 5000) }
266
+ }
267
+
235
268
  function syncChip() {
236
269
  const sy = (state.health && state.health.sync) || null
237
270
  if (!sy || sy.level === 'ok' || !(sy.items || []).length) return ''
@@ -409,6 +442,7 @@ const FILTERS = {
409
442
  function boardFilter() {
410
443
  const params = (state.route && state.route.params) || new URLSearchParams()
411
444
  const f = readFilter(params, window.localStorage)
445
+ state.boardQ = (params.get('q') || '').slice(0, 200) // 0.8.6: board search
412
446
  // A bare #/ restored from localStorage: put it in the address bar so the
413
447
  // view is linkable as it stands.
414
448
  if (f.from === 'storage') history.replaceState(null, '', hashFor(f))
@@ -426,6 +460,7 @@ function setBoardFilter(next) {
426
460
  function hashFor(f) {
427
461
  let h = filterHash(f)
428
462
  if (state.boardSpace) h += (h.includes('?') ? '&' : '?') + 'space=' + encodeURIComponent(state.boardSpace)
463
+ if (state.boardQ && f.status !== 'closed') h += (h.includes('?') ? '&' : '?') + 'q=' + encodeURIComponent(state.boardQ)
429
464
  return state.route && state.route.name === 'waiting' ? h.replace(/^#\//, '#/waiting') : h
430
465
  }
431
466
 
@@ -495,10 +530,14 @@ async function viewBoard() {
495
530
  const inSpace = sp ? shown.filter((s) => s.space === sp) : shown
496
531
  const inRepos = applyFilter(inSpace, { repos: f.repos })
497
532
  const count = (k) => k === 'closed' ? (closedCount == null ? '—' : closedCount) : inRepos.filter((s) => inStatus(s, k)).length
498
- const list = filter === 'closed' ? [] : applyFilter(inSpace, f)
533
+ const bq = (state.boardQ || '').trim()
534
+ // 0.8.6: a search looks through every session the board has (not only today's), folded ones too
535
+ const list = filter === 'closed' ? [] : bq ? applyFilter(sp ? sessions.filter((s) => s.space === sp) : sessions, f).filter((s) => searchMatches(s, bq)).sort((a, b) => (rank[a.status] - rank[b.status]) || (Date.parse(b.lastActivityAt || 0) - Date.parse(a.lastActivityAt || 0))) : applyFilter(inSpace, f)
536
+ let closedHits = 0
537
+ if (bq && filter !== 'closed') { try { const c = await api('GET', '/v1/sessions/closed?limit=1&q=' + encodeURIComponent(bq) + (state.machine ? '&machine=' + encodeURIComponent(state.machine) : '')); closedHits = c.total || 0 } catch {} }
499
538
  let html = '<div class="pagehead"><div><h1>Sessions</h1><div class="sub">Every Claude Code session on ' + (multi() ? 'your machines' : 'this machine') + (agentNames().length ? ', plus ' + esc(agentNames().join(' and ')) : '') + ', grouped by what needs you.</div>' + syncChip() + '</div>' +
500
539
  '<div class="actions">' + (multi() || agentNames().length ? selectHtml({ id: 'machineFilter', cls: 'btn', label: 'Machine', value: state.machine,
501
- options: [['', 'All machines'], ...state.health.machines.map((m) => [m, m + (m === state.health.machine ? ' (this one)' : '')]), ...agentNames().map((m) => [m, m + ' (agent)'])] }) : '') +
540
+ options: [['', 'All machines'], ...state.health.machines.map((m) => [m, mEmoji(m) + ' ' + m + thisOneSuffix(m, state.health)]), ...agentNames().map((m) => [m, m + ' (agent)'])] }) : '') +
502
541
  '<a class="btn primary" href="#/catchup/new">' + icon('coffee') + 'Catch me up</a>' +
503
542
  '<a class="btn" href="#/digest">' + icon('newspaper') + 'Today’s digest →</a></div></div>'
504
543
  html += syncDetails() + staleLine(sessions)
@@ -517,6 +556,13 @@ async function viewBoard() {
517
556
  return closedWire()
518
557
  }
519
558
  html += repoRow(inSpace, f)
559
+ html += boardSearch(bq, closedHits, list.length)
560
+ if (bq) {
561
+ if (list.length) html += '<div class="grid mt">' + list.map(card).join('') + '</div>'
562
+ $app.innerHTML = banners() + html
563
+ revealStat()
564
+ return boardSearchWire()
565
+ }
520
566
  if (!shown.length && !empties.length && !parked.length) {
521
567
  html += '<div class="empty mt"><p><strong>No sessions yet.</strong></p><p>Start Claude Code with the mod loaded (<code>claude --plugin-dir ./mod</code>) or install the settings hooks (<code>leftoff init --hooks</code>). Sessions show up here as soon as you send a prompt.</p></div>'
522
568
  } else if (!list.length && shown.length) {
@@ -532,6 +578,33 @@ async function viewBoard() {
532
578
  if (closedCount > 0) html += '<div class="closed-link"><button type="button" class="linkish" data-action="filter" data-filter="closed">Closed (' + esc(closedCount) + ') →</button></div>'
533
579
  $app.innerHTML = banners() + html
534
580
  revealStat()
581
+ boardSearchWire()
582
+ }
583
+
584
+ // 0.8.6: search the board. The words live in the URL (?q=), typing keeps the
585
+ // focus, and matches among closed sessions are one tap away.
586
+ function boardSearch(q, closedHits, n) {
587
+ return '<div class="board-search-row"><div class="set-search board-search">' + icon('search') + '<input id="boardQ" type="search" placeholder="Find a session: title, repo, branch, folder or id" aria-label="Find a session" autocomplete="off" autocapitalize="off" spellcheck="false" value="' + esc(q) + '"></div>' +
588
+ (q ? '<div class="board-search-note">' + (n ? esc(n + (n === 1 ? ' match' : ' matches')) : 'No open or recent session matches.') +
589
+ (closedHits ? ' <button type="button" class="linkish" data-action="closed-search" data-q="' + esc(q) + '">' + esc(closedHits + ' closed ' + (closedHits === 1 ? 'match' : 'matches')) + ' →</button>' : '') + '</div>' : '') + '</div>'
590
+ }
591
+ let boardQTimer = null
592
+ function boardSearchWire() {
593
+ const q = document.getElementById('boardQ')
594
+ if (!q) return
595
+ q.addEventListener('input', () => {
596
+ clearTimeout(boardQTimer)
597
+ boardQTimer = setTimeout(async () => {
598
+ const pos = q.selectionStart
599
+ state.boardQ = q.value
600
+ try { history.replaceState(null, '', hashFor(state.boardFilter || { repos: [], status: null })) } catch {}
601
+ state.route = parseRoute()
602
+ await viewBoard()
603
+ const again = document.getElementById('boardQ')
604
+ if (again) { again.focus(); try { again.setSelectionRange(pos, pos) } catch {} }
605
+ }, 160)
606
+ })
607
+ q.addEventListener('keydown', (ev) => { if (ev.key === 'Escape' && q.value) { ev.preventDefault(); q.value = ''; q.dispatchEvent(new Event('input')) } })
535
608
  }
536
609
 
537
610
  // Closed (0.6.7): every ended session on every machine, newest first, with
@@ -888,7 +961,7 @@ async function viewSession(id) {
888
961
  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>' +
889
962
  '<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>'
890
963
  html += '<div class="facts">' +
891
- 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)) +
964
+ fact('Machine', mEmoji(s.machine) + ' ' + s.machine + (thisOneSuffix(s.machine, state.health) || (s.isLocal ? '' : ' (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)) +
892
965
  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) +
893
966
  fact('Open?', s.live === 'open' ? 'open' + (s.liveVia === 'pid' ? ' (process ' + s.pid + ' running)' : ' (heartbeat)') : s.live === 'closed' ? 'closed ' + ago(s.closedAt) : 'unknown') +
894
967
  fact('Terminal', (s.terminal ? s.terminal.label : s.codingAgent === 'codex' ? ({ 'codex-ide': 'Codex in the IDE', 'codex-app': 'Codex app', 'codex-exec': 'codex exec' }[s.surface] || 'Codex CLI') : '—') + (s.tty ? ' · ' + s.tty.replace('/dev/', '') : '') + ' · tab cp:' + (s.shortId || '')) + '</div>'
@@ -3092,6 +3165,19 @@ document.addEventListener('click', async (ev) => {
3092
3165
  toast(u.error ? 'Couldn’t check: ' + u.error : u.available ? 'Update available: ' + (u.remote.version ? 'v' + u.remote.version : u.remote.sha.slice(0, 7)) : 'You’re up to date')
3093
3166
  await refreshHealth(); return render()
3094
3167
  }
3168
+ if (action === 'machine-emoji') { ev.preventDefault(); ev.stopPropagation(); return openEmojiSheet(btn.dataset.machine) }
3169
+ if (action === 'emoji-close') return closeEmojiSheet()
3170
+ if (action === 'emoji-pick') return setMachineEmoji(btn.dataset.machine, btn.dataset.emoji)
3171
+ if (action === 'emoji-own') {
3172
+ const v = (document.getElementById('emojiOwn') || {}).value || ''
3173
+ if (!isEmoji(v)) return toast('Type one emoji', 3000)
3174
+ return setMachineEmoji(btn.dataset.machine, v.trim())
3175
+ }
3176
+ if (action === 'closed-search') {
3177
+ const f = state.boardFilter || readFilter(state.route && state.route.params, window.localStorage)
3178
+ closedUi.q = btn.dataset.q || ''; closedUi.limit = CLOSED_PAGE; state.boardQ = ''
3179
+ return setBoardFilter({ repos: f.repos, status: 'closed' })
3180
+ }
3095
3181
  if (action === 'filter') {
3096
3182
  const f = state.boardFilter || readFilter(state.route && state.route.params, window.localStorage)
3097
3183
  return setBoardFilter({ repos: f.repos, status: f.status === btn.dataset.filter ? null : btn.dataset.filter })
package/ui/machines.js ADDED
@@ -0,0 +1,43 @@
1
+ // Machine emojis (0.8.6): each Mac gets one, shown wherever its name is.
2
+ // A default comes from the name (a few words match: night/bat → 🦇,
3
+ // shade/moon → 🌚 …; else a hash into a small set of distinct, widely
4
+ // supported emoji), never the same one for two Macs the board knows when it
5
+ // can help it. You can change it (board: tap the Mac's chip; CLI: `leftoff
6
+ // machine emoji`); the choice lives in the synced folder (machines/<name>.json)
7
+ // so every Mac and the relay show the same one. Shared by the daemon and the board.
8
+ export const EMOJI_SET = ['🦊', '🐙', '🦉', '🐳', '🦄', '🐢', '🦁', '🐝', '🌵', '🍄', '🚀', '⚡', '🌊', '🔥', '🍀', '🎲', '🧊', '🌻', '🐧', '🦖', '🍉', '🎸', '🛸', '🐸']
9
+ const WORDS = [
10
+ [/night|bat|dark/, '🦇'], [/shade|shadow|moon|luna/, '🌚'], [/sun|day|sol/, '🌞'], [/laptop|book|air/, '💻'],
11
+ [/studio|desk|tower|pro|work/, '🖥️'], [/pi$|raspberry/, '🥧'], [/cloud|server|box/, '☁️'], [/home/, '🏠'],
12
+ ]
13
+ export const PICKER_SET = [...new Set(['🦇', '🌚', '🌞', '💻', '🖥️', ...EMOJI_SET, '🏠', '☁️', '🥧', '🐱', '🐶', '🍕', '🌈', '⭐'])]
14
+
15
+ function hash(s) { let h = 2166136261; for (const c of String(s)) { h ^= c.codePointAt(0); h = Math.imul(h, 16777619) >>> 0 } return h }
16
+ const key = (n) => String(n || '').toLowerCase().replace(/\.local$/, '')
17
+
18
+ // A valid machine emoji: a short run with at least one pictograph (flags too), no letters, digits or markup.
19
+ export function isEmoji(v) {
20
+ const s = String(v == null ? '' : v).trim()
21
+ if (!s || s.length > 16 || /[\s<>&"'`A-Za-z0-9]/.test(s)) return false
22
+ return /\p{Extended_Pictographic}|\p{Regional_Indicator}/u.test(s)
23
+ }
24
+
25
+ // The one a name gets when nobody chose, skipping `taken` when it can.
26
+ export function defaultEmoji(name, taken = new Set()) {
27
+ const k = key(name)
28
+ for (const [re, e] of WORDS) if (re.test(k) && !taken.has(e)) return e
29
+ const start = hash(k) % EMOJI_SET.length
30
+ for (let i = 0; i < EMOJI_SET.length; i++) { const e = EMOJI_SET[(start + i) % EMOJI_SET.length]; if (!taken.has(e)) return e }
31
+ return EMOJI_SET[start]
32
+ }
33
+
34
+ // names: every machine the board knows; chosen: { name: emoji } from the folder.
35
+ // -> { name: emoji }, stable for a given set of names (sorted, chosen ones first).
36
+ export function machineEmojis(names, chosen = {}) {
37
+ const out = {}
38
+ const taken = new Set()
39
+ const all = [...new Set((names || []).filter(Boolean))].sort((a, b) => key(a).localeCompare(key(b)))
40
+ for (const n of all) if (isEmoji(chosen[n])) { out[n] = chosen[n]; taken.add(chosen[n]) }
41
+ for (const n of all) if (!out[n]) { out[n] = defaultEmoji(n, taken); taken.add(out[n]) }
42
+ return out
43
+ }
package/ui/repos.js CHANGED
@@ -198,3 +198,22 @@ export function splitChips(chips, selected = [], max = 8) {
198
198
  for (const c of chips.slice(max)) (isSel(c) ? shown : more).push(c)
199
199
  return { shown, more }
200
200
  }
201
+
202
+ // 0.8.6: find a session on the board. Every word must match one of: title,
203
+ // repo, branch, worktree, folder, last reply, first prompt, checkpoint text
204
+ // (extra), or the session id by prefix (paste it from /status, or cp:xxxxxx).
205
+ export function searchMatches(s, q, extra = []) {
206
+ const words = String(q || '').toLowerCase().trim().split(/\s+/).filter(Boolean)
207
+ if (!words.length) return true
208
+ const cp = s.lastCheckpoint || null
209
+ const hay = [s.title, s.repoLabel, s.repoName, s.repo && s.repo.branch, s.repo && s.repo.github, s.worktree && s.worktree.name, s.worktree && s.worktree.branch, s.cwd, s.lastPreview, s.firstPrompt, s.machine,
210
+ cp && cp.doing, cp && cp.next, cp && cp.stoppedAt, ...extra].filter(Boolean).map((h) => String(h).toLowerCase())
211
+ const id = String(s.id || '').toLowerCase()
212
+ const short = String(s.shortId || '').toLowerCase()
213
+ return words.every((w) => {
214
+ const bare = w.replace(/^cp:/, '')
215
+ if (bare.length >= 4 && (id.startsWith(bare) || (short && short === bare))) return true
216
+ if (w.startsWith('cp:')) return false
217
+ return hay.some((h) => h.includes(w))
218
+ })
219
+ }
package/ui/viewer.js CHANGED
@@ -49,3 +49,18 @@ async function wouldPrompt(permissions) {
49
49
  }
50
50
  return known
51
51
  }
52
+
53
+ // 0.8.6: which machine is "this one" for the person looking: the board's own
54
+ // Mac on its localhost; over the tailnet the Mac whois named (when the folder
55
+ // knows it); over the relay or unknown, none (never the serving Mac by default).
56
+ export function viewerMachine(health) {
57
+ const h = health || {}
58
+ const v = h.viewer || null
59
+ if (!v) return null
60
+ if (v.remote === false) return v.machine || h.machine || null
61
+ return v.machine || null
62
+ }
63
+ export function thisOneSuffix(machine, health) {
64
+ const me = viewerMachine(health)
65
+ return me && machine === me ? ' (this one)' : ''
66
+ }