@leftoff/cli 0.0.0-stage → 0.6.2
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/.claude-plugin/marketplace.json +14 -0
- package/CHANGELOG.md +553 -0
- package/LICENSE.md +9 -0
- package/README.md +28 -2
- package/assets/sample-transcript.jsonl +12 -0
- package/bin/checkpoints.js +7 -0
- package/bin/leftoff.js +585 -0
- package/hooks/README.md +22 -0
- package/hooks/settings-hooks.example.json +82 -0
- package/mod/.claude-plugin/plugin.json +15 -0
- package/mod/hooks/core.js +1397 -0
- package/mod/hooks/hooks.json +4 -0
- package/mod/hooks/register.js +1006 -0
- package/package.json +45 -4
- package/templates/agent-feed-worker/README.md +45 -0
- package/templates/agent-feed-worker/src/index.js +69 -0
- package/templates/agent-feed-worker/wrangler.toml +20 -0
- package/ui/app.css +1264 -0
- package/ui/app.js +2979 -0
- package/ui/apple-touch-icon.png +0 -0
- package/ui/changelog-md.js +64 -0
- package/ui/favicon-32.png +0 -0
- package/ui/favicon-badge-32.png +0 -0
- package/ui/favicon-badge.svg +1 -0
- package/ui/favicon.ico +0 -0
- package/ui/favicon.svg +1 -0
- package/ui/go.js +68 -0
- package/ui/icon-192.png +0 -0
- package/ui/icon-512.png +0 -0
- package/ui/icon-maskable-512.png +0 -0
- package/ui/icons.js +41 -0
- package/ui/index.html +55 -0
- package/ui/manifest.webmanifest +37 -0
- package/ui/needs.js +197 -0
- package/ui/offline.css +11 -0
- package/ui/offline.html +24 -0
- package/ui/pair.html +31 -0
- package/ui/picker.js +294 -0
- package/ui/relay-client.js +187 -0
- package/ui/relay-crypto.js +155 -0
- package/ui/relay-pair.js +48 -0
- package/ui/repos.js +167 -0
- package/ui/settings-nav.js +23 -0
- package/ui/summary-examples.js +66 -0
- package/ui/sw.js +34 -0
- package/ui/theme.js +41 -0
- package/ui/vendor/LUCIDE-LICENSE.txt +45 -0
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
// Leftoff mod (Leftoff was called Checkpoints before v0.4.5): the Claude Code
|
|
2
|
+
// side of the Leftoff system.
|
|
3
|
+
//
|
|
4
|
+
// It posts session lifecycle and activity to the local daemon
|
|
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
|
|
7
|
+
// a pane listing every open session. When the daemon is down it spools events
|
|
8
|
+
// to ~/checkpoints/.spool, keeps a session record in $.store, tracks idle
|
|
9
|
+
// itself and writes checkpoint markdown straight into the folder.
|
|
10
|
+
//
|
|
11
|
+
// Static-analysis rules this file follows (claude plugin validate): every mods
|
|
12
|
+
// API call is written in full ($.ns.method), $ is only passed to functions
|
|
13
|
+
// declared at the top level of this file, event names are string literals, and
|
|
14
|
+
// each event is registered once per matcher.
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_CONFIG, DEFAULT_PORT, mergeConfig, resolveMachine, applyEvent, checkpointDue, buildCheckpointPrompt,
|
|
17
|
+
parseCheckpointReply, heuristicCheckpoint, renderCheckpointMd, checkpointId, checkpointFileName,
|
|
18
|
+
sessionFolderName, id8, clip, toIso, resumeCommand, repoName,
|
|
19
|
+
shortId, formatTabTitle, parseTabTitle, detectTerminal, parseProcLine, trailingQuestion,
|
|
20
|
+
parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
|
|
21
|
+
listHintFrom, extractChecklists, parseTodoArgs, parseDoneArgs,
|
|
22
|
+
} from './core.js'
|
|
23
|
+
|
|
24
|
+
const PANE_ID = 'checkpoints'
|
|
25
|
+
const POST_TIMEOUT_MS = 800
|
|
26
|
+
const END_TIMEOUT_MS = 1000
|
|
27
|
+
const MODEL_TIMEOUT_MS = 20000
|
|
28
|
+
|
|
29
|
+
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
30
|
+
const MOD_VERSION = '0.6.2' // keep in step with .claude-plugin/plugin.json
|
|
31
|
+
|
|
32
|
+
const st = {
|
|
33
|
+
internal: false,
|
|
34
|
+
ready: false,
|
|
35
|
+
sessionId: null,
|
|
36
|
+
root: null,
|
|
37
|
+
token: null,
|
|
38
|
+
port: DEFAULT_PORT,
|
|
39
|
+
cfg: DEFAULT_CONFIG,
|
|
40
|
+
record: null, // local mirror of the daemon's session record, kept with the shared applyEvent
|
|
41
|
+
daemonUp: null,
|
|
42
|
+
heartbeat: null,
|
|
43
|
+
paneTimer: null,
|
|
44
|
+
paneRows: [],
|
|
45
|
+
paneNote: '',
|
|
46
|
+
checkpointing: false,
|
|
47
|
+
lastCheckpoint: null,
|
|
48
|
+
nameCommand: 'cp-name',
|
|
49
|
+
commands: null,
|
|
50
|
+
modHealthSent: false,
|
|
51
|
+
machine: 'unknown',
|
|
52
|
+
proc: null, // { pid, tty, pidStartedAt } of this Claude Code process
|
|
53
|
+
term: null, // detectTerminal() of this session's environment
|
|
54
|
+
titleOff: false, // CLAUDE_CODE_DISABLE_TERMINAL_TITLE: Claude Code draws no title, so the mod writes OSC 2 itself
|
|
55
|
+
tabTitle: null, // the tab title last applied
|
|
56
|
+
tabMarker: false, // v0.5.5: the daemon says Go needs "cp:<id>" in this tab's title
|
|
57
|
+
paneSel: 0,
|
|
58
|
+
paneRepo: null, // the pane's repo filter (f)
|
|
59
|
+
paneAll: [],
|
|
60
|
+
hello: null, // what session.opened said (folder, repo, model, surface, process, terminal), to say again when asked
|
|
61
|
+
helloAt: 0,
|
|
62
|
+
asks: {}, // board requests this session has open: id -> { tool, input, done }
|
|
63
|
+
replyLoop: false, // the long-poll for board replies is running
|
|
64
|
+
listSig: null, // v0.5.1: the signature of this session's lists Claude last heard about
|
|
65
|
+
listPending: false, // a list was saved or ticked here: tell Claude with the next prompt
|
|
66
|
+
paneNeeds: 0, // sessions that need you (the pane's top line)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Millisecond precision: the daemon dedupes events per (sessionId, type, at).
|
|
70
|
+
function nowIso() { return new Date().toISOString() }
|
|
71
|
+
|
|
72
|
+
function rand() {
|
|
73
|
+
const a = new Uint32Array(1)
|
|
74
|
+
crypto.getRandomValues(a)
|
|
75
|
+
return a[0].toString(16).padStart(8, '0')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function raceTimeout(promise, msec, $) {
|
|
79
|
+
return Promise.race([
|
|
80
|
+
promise,
|
|
81
|
+
new Promise((resolve) => { $.clock.after(msec, () => resolve({ timedOut: true })) }),
|
|
82
|
+
])
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function api($, method, path, body, timeoutMs) {
|
|
86
|
+
const url = 'http://127.0.0.1:' + st.port + path
|
|
87
|
+
const init = { method, headers: { 'content-type': 'application/json', authorization: 'Bearer ' + (st.token || '') } }
|
|
88
|
+
if (body !== undefined) init.body = JSON.stringify(body)
|
|
89
|
+
try {
|
|
90
|
+
const r = await raceTimeout($.http.fetch(url, init), timeoutMs || POST_TIMEOUT_MS, $)
|
|
91
|
+
if (r.timedOut) { st.daemonUp = false; return null }
|
|
92
|
+
st.daemonUp = r.status !== 401 && r.status < 500
|
|
93
|
+
let json = null
|
|
94
|
+
try { json = JSON.parse(r.text) } catch {}
|
|
95
|
+
return { status: r.status, ok: r.ok, json }
|
|
96
|
+
} catch {
|
|
97
|
+
st.daemonUp = false
|
|
98
|
+
return null
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Every event goes through here: post, or spool a unique file (fs.write isn't atomic).
|
|
103
|
+
async function sendOrSpool($, type, data, timeoutMs) {
|
|
104
|
+
if (st.internal || !st.sessionId) return null
|
|
105
|
+
const ev = { v: 1, type, sessionId: st.sessionId, at: nowIso(), capture: 'mod', machine: st.machine, data: data || {} }
|
|
106
|
+
st.record = applyEvent(st.record || { id: st.sessionId, turns: 0, turnsSinceCheckpoint: 0, backgroundTasks: 0 }, ev)
|
|
107
|
+
const r = await api($, 'POST', '/v1/events', { events: [ev] }, timeoutMs)
|
|
108
|
+
if (r && r.ok) {
|
|
109
|
+
const j = r.json || {}
|
|
110
|
+
if (j.session && j.session.id === st.sessionId && j.session.title) st.daemonTitle = j.session.title
|
|
111
|
+
// v0.5.5: the daemon says when Go needs the "cp:<id>" marker in this tab's title
|
|
112
|
+
if (j.session && j.session.id === st.sessionId && typeof j.session.tabMarker === 'boolean') st.tabMarker = j.session.tabMarker
|
|
113
|
+
if (j.hello && type !== 'session.opened' && type !== 'session.ended') await sayHelloAgain($)
|
|
114
|
+
return j
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
await $.fs.write(st.root + '/.spool/' + st.machine + '/' + st.sessionId + '/' + Date.now() + '-' + rand() + '.json', JSON.stringify({ events: [ev] }) + '\n')
|
|
118
|
+
} catch {}
|
|
119
|
+
try {
|
|
120
|
+
await $.store.set('session:' + st.sessionId, {
|
|
121
|
+
id: st.sessionId, title: st.record.title || null, cwd: st.record.cwd || null, repo: st.record.repo || null,
|
|
122
|
+
lastActivityAt: st.record.lastActivityAt || null, lastHeartbeatAt: nowIso(), endedAt: st.record.endedAt || null,
|
|
123
|
+
next: st.lastCheckpoint ? st.lastCheckpoint.next : null,
|
|
124
|
+
})
|
|
125
|
+
} catch {}
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// v0.4.8: the daemon restarted since this session said hello (a restart used
|
|
130
|
+
// to forget a session that had no turns yet), so it doesn't know this
|
|
131
|
+
// session's folder, process or terminal. Say it again (at most every 5 minutes).
|
|
132
|
+
async function sayHelloAgain($) {
|
|
133
|
+
if (!st.hello || Date.now() - st.helloAt < 300000) return
|
|
134
|
+
st.helloAt = Date.now()
|
|
135
|
+
await sendOrSpool($, 'session.opened', { ...st.hello, again: true })
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function isInternal($) {
|
|
139
|
+
// 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
|
+
}
|
|
142
|
+
|
|
143
|
+
async function loadSetup($) {
|
|
144
|
+
const home = (await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || ''
|
|
145
|
+
st.root = ((await $.env.get('LEFTOFF_DIR')) || (await $.env.get('CHECKPOINTS_DIR')) || home + '/checkpoints').replace(/\/+$/, '')
|
|
146
|
+
try { st.token = String(await $.fs.read(st.root + '/.secret/token')).trim() } catch { st.token = null }
|
|
147
|
+
try {
|
|
148
|
+
st.cfg = mergeConfig(DEFAULT_CONFIG, JSON.parse(String(await $.fs.read(st.root + '/config.json'))))
|
|
149
|
+
st.port = Number(st.cfg.port) || DEFAULT_PORT
|
|
150
|
+
} catch { st.cfg = DEFAULT_CONFIG }
|
|
151
|
+
const port = await $.env.get('CHECKPOINTS_PORT')
|
|
152
|
+
if (port) st.port = Number(port)
|
|
153
|
+
// Same rule as the daemon: CHECKPOINTS_MACHINE > config machineNames[hostname] > config machine > hostname
|
|
154
|
+
let hostname = ''
|
|
155
|
+
try { const r = await $.process.run(['hostname']); if (r.exitCode === 0) hostname = String(r.stdout || '').trim() } catch {}
|
|
156
|
+
if (!hostname) hostname = (await $.env.get('HOSTNAME')) || (await $.env.get('COMPUTERNAME')) || ''
|
|
157
|
+
st.machine = resolveMachine({ env: await $.env.get('CHECKPOINTS_MACHINE'), hostname, cfg: st.cfg })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function readRepo($, cwd) {
|
|
161
|
+
let repo = null
|
|
162
|
+
try {
|
|
163
|
+
const r = await $.session.repo()
|
|
164
|
+
if (r) repo = { root: r.root, name: r.root ? r.root.split('/').filter(Boolean).pop() : r.name }
|
|
165
|
+
} catch {}
|
|
166
|
+
if (repo) {
|
|
167
|
+
try {
|
|
168
|
+
const out = await $.process.run(['git', '-C', cwd, 'symbolic-ref', '--short', '-q', 'HEAD'])
|
|
169
|
+
const b = String(out.stdout || '').trim()
|
|
170
|
+
if (out.exitCode === 0 && b) repo.branch = b // empty on a detached HEAD
|
|
171
|
+
} catch {}
|
|
172
|
+
}
|
|
173
|
+
return repo
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function heartbeat($) {
|
|
177
|
+
if (st.internal || !st.sessionId) return
|
|
178
|
+
const res = await sendOrSpool($, 'heartbeat', { inTurn: !!(st.record && st.record.inTurn) })
|
|
179
|
+
if (res && !st.modHealthSent) await reportModHealth($)
|
|
180
|
+
if (res && st.daemonTitle) await noteTitle($, st.daemonTitle)
|
|
181
|
+
else if (res && st.tabTitle && !(st.record && st.record.inTurn)) await applyTabTitle($, null) // the marker came or went
|
|
182
|
+
if (res && res.checkpointDue && res.checkpointDue.sessionId === st.sessionId) {
|
|
183
|
+
await makeCheckpoint($, res.checkpointDue.reason, null)
|
|
184
|
+
} else if (!res && st.record) {
|
|
185
|
+
// Daemon down: the same dormancy rules, run locally (§4).
|
|
186
|
+
const reason = checkpointDue(st.record, Date.now(), st.cfg)
|
|
187
|
+
if (reason) await makeCheckpoint($, reason, null)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// The commands, in the order they're tried. Claude Code 2.1.295's built-ins
|
|
192
|
+
// own /checkpoint (an alias of /rewind) and /name (an alias of /rename), so
|
|
193
|
+
// ours share a /cp prefix; each command lists fallbacks in case a later
|
|
194
|
+
// release takes a name. -> st.commands: [{ key, want, name, ok, error }]
|
|
195
|
+
const COMMANDS = [
|
|
196
|
+
{ key: 'checkpoint', names: ['cp', 'cp-save'], description: 'Leftoff: save a checkpoint of this session now (--short / --long for this one)', argumentHint: '[--short|--long] [note]', immediate: true },
|
|
197
|
+
{ key: 'name', names: ['cp-name'], description: 'Leftoff: name this session (runs /rename, tells the board)', argumentHint: '<title>', immediate: false },
|
|
198
|
+
{ key: 'pane', names: ['leftoff'], description: 'Leftoff: every open session and its status (/leftoff open: the board)', argumentHint: '[open]', immediate: true },
|
|
199
|
+
// The pane's pre-0.4.5 name, kept so the habit keeps working.
|
|
200
|
+
{ key: 'pane-old', names: ['checkpoints'], description: 'Leftoff (old name): same as /leftoff', argumentHint: '[open]', immediate: true },
|
|
201
|
+
// v0.5.0: one recap across your sessions; same as `leftoff catchup`.
|
|
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
|
+
// v0.5.1: save the checklist Claude just wrote; tick its items.
|
|
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
|
+
{ 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
|
+
]
|
|
207
|
+
|
|
208
|
+
async function registerCommands($) {
|
|
209
|
+
const results = []
|
|
210
|
+
for (const c of COMMANDS) {
|
|
211
|
+
const r = { key: c.key, want: c.names[0], name: null, ok: false, error: null, tried: [] }
|
|
212
|
+
for (const name of c.names) {
|
|
213
|
+
r.tried.push(name)
|
|
214
|
+
try {
|
|
215
|
+
await $.command.register({ name, description: c.description, argumentHint: c.argumentHint, immediate: c.immediate })
|
|
216
|
+
r.name = name
|
|
217
|
+
r.ok = true
|
|
218
|
+
r.error = null
|
|
219
|
+
break
|
|
220
|
+
} catch (err) {
|
|
221
|
+
r.error = String((err && err.message) || err).slice(0, 200)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
results.push(r)
|
|
225
|
+
}
|
|
226
|
+
st.commands = results
|
|
227
|
+
if (results.some((x) => x.key === 'name' && x.ok)) st.nameCommand = results.find((x) => x.key === 'name').name
|
|
228
|
+
const failed = results.filter((x) => !x.ok)
|
|
229
|
+
const moved = results.filter((x) => x.ok && x.name !== x.want)
|
|
230
|
+
try {
|
|
231
|
+
if (failed.length) $.ui.log('commands failed to register: ' + failed.map((x) => '/' + x.want + ' (' + x.error + ')').join('; '))
|
|
232
|
+
if (moved.length) $.ui.log('using ' + moved.map((x) => '/' + x.name + ' instead of /' + x.want).join(', '))
|
|
233
|
+
} catch {}
|
|
234
|
+
st.modHealthSent = false
|
|
235
|
+
await reportModHealth($)
|
|
236
|
+
return results
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Tell the daemon which commands this session got, so `leftoff doctor`
|
|
240
|
+
// and the board can show a failed registration. Best effort: if the daemon is
|
|
241
|
+
// down, the heartbeat tries again.
|
|
242
|
+
async function reportModHealth($) {
|
|
243
|
+
if (st.modHealthSent || !st.commands || !st.sessionId) return
|
|
244
|
+
const body = { sessionId: st.sessionId, machine: st.machine, modVersion: MOD_VERSION, at: nowIso(), commands: st.commands.map((c) => ({ key: c.key, want: c.want, name: c.name, ok: c.ok, error: c.error })) }
|
|
245
|
+
const r = await api($, 'POST', '/v1/mod/health', body, 1500)
|
|
246
|
+
if (r && r.ok) st.modHealthSent = true
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// /cp [--short|--long] [note]: the flag picks the length for this one checkpoint.
|
|
250
|
+
async function checkpointCommand($, args) {
|
|
251
|
+
const a = parseCheckpointArgs(args)
|
|
252
|
+
const cp = await makeCheckpoint($, 'manual', a.note, a.length)
|
|
253
|
+
if (!cp) return { text: 'Leftoff: a checkpoint is already being made.' }
|
|
254
|
+
return {}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ------------------------------------------------------------ lists (v0.5.1)
|
|
258
|
+
|
|
259
|
+
// This session's saved lists, when they changed since Claude last heard
|
|
260
|
+
// (the daemon's signature moved) -> the context text, else null. One local
|
|
261
|
+
// GET per prompt; the daemon reads the lists from a 2-second cache.
|
|
262
|
+
async function listContextFor($) {
|
|
263
|
+
if (st.internal || !st.sessionId || st.daemonUp === false && !st.listPending) return null
|
|
264
|
+
const r = await api($, 'GET', '/v1/sessions/' + encodeURIComponent(st.sessionId) + '/lists', undefined, 600)
|
|
265
|
+
if (!r || !r.ok || !r.json) return null
|
|
266
|
+
const sig = r.json.signature || null
|
|
267
|
+
if (!sig || (sig === st.listSig && !st.listPending)) { st.listPending = false; return null }
|
|
268
|
+
st.listSig = sig
|
|
269
|
+
st.listPending = false
|
|
270
|
+
return r.json.context || null
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function listLines(l, max = 30) {
|
|
274
|
+
const out = []
|
|
275
|
+
for (const it of (l.items || []).slice(0, max)) out.push(' ' + String(it.n).padStart(2) + ' ' + (it.done ? '[x]' : '[ ]') + ' ' + clip(it.text, 110))
|
|
276
|
+
if ((l.items || []).length > max) out.push(' … ' + ((l.items || []).length - max) + ' more')
|
|
277
|
+
return out
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// /cp-todo [name] [--pick [N]]: save the most recent checklist Claude wrote
|
|
281
|
+
// in this session (or the one picked) as a list in the folder.
|
|
282
|
+
async function saveTodo($, args) {
|
|
283
|
+
if (st.internal || !st.sessionId) return { text: 'Leftoff: not in a tracked session.' }
|
|
284
|
+
const a = parseTodoArgs(args)
|
|
285
|
+
let messages = []
|
|
286
|
+
try { messages = await $.session.messages() } catch {}
|
|
287
|
+
const found = extractChecklists(messages, { minItems: 2, fallback: st.daemonTitle || (st.record && st.record.title) || null }).reverse()
|
|
288
|
+
if (!found.length) return { text: 'Leftoff: no checklist from Claude in this session yet. Ask for one ("list the next steps to …"); Leftoff picks up "- [ ]" checklists and numbered or bulleted lists under a heading like "Next steps".' }
|
|
289
|
+
if (a.pick === true) {
|
|
290
|
+
const lines = ['Checklists Claude wrote in this session (newest first):', '']
|
|
291
|
+
for (let k = 0; k < found.length && k < 12; k++) {
|
|
292
|
+
const c = found[k]
|
|
293
|
+
lines.push(' ' + (k + 1) + '. ' + c.name + ' · ' + c.items.length + ' items')
|
|
294
|
+
for (const it of c.items.slice(0, 2)) lines.push(' ' + clip(it.text, 90))
|
|
295
|
+
}
|
|
296
|
+
lines.push('', 'Save one: /cp-todo --pick <n> [name]')
|
|
297
|
+
return { text: lines.join('\n') }
|
|
298
|
+
}
|
|
299
|
+
const c = typeof a.pick === 'number' ? found[a.pick - 1] : found[0]
|
|
300
|
+
if (!c) return { text: 'Leftoff: there\'s no list ' + a.pick + ' (/cp-todo --pick shows them).' }
|
|
301
|
+
const r = await api($, 'POST', '/v1/lists', { sessionId: st.sessionId, items: c.items, name: a.name || c.name }, 5000)
|
|
302
|
+
if (!r) return { text: 'Leftoff: the daemon isn\'t running, so the list wasn\'t saved. Start it with `leftoff serve`, or save it from a terminal: leftoff todo capture ' + st.sessionId + (a.name ? ' --name ' + JSON.stringify(a.name) : '') }
|
|
303
|
+
if (!r.ok || !r.json || !r.json.list) return { text: 'Leftoff: couldn\'t save the list' + (r.json && r.json.error ? ': ' + r.json.error.message : ' (' + r.status + ')') }
|
|
304
|
+
const l = r.json.list
|
|
305
|
+
st.listPending = true // Claude hears about it with the next prompt
|
|
306
|
+
return { text: ['Saved "' + l.name + '" (' + l.total + ' items) → lists/' + l.ref + '.md', ...listLines(l), '',
|
|
307
|
+
'Tick items: /cp-done 3 · or just tell Claude "step 3 is done" (it runs `leftoff todo done ' + l.ref + ' 3`) · or tick them on the board / phone under Lists.'].join('\n') }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// /cp-done <n…|words> [in <list>], /cp-done undo <n>
|
|
311
|
+
async function tickTodo($, args) {
|
|
312
|
+
if (st.internal || !st.sessionId) return { text: 'Leftoff: not in a tracked session.' }
|
|
313
|
+
const a = parseDoneArgs(args)
|
|
314
|
+
if (!a.numbers.length && !a.text) return { text: 'Usage: /cp-done <n> [<n>…] [in <list>] · /cp-done undo <n>' }
|
|
315
|
+
let ref = a.list
|
|
316
|
+
if (!ref) {
|
|
317
|
+
const r = await api($, 'GET', '/v1/sessions/' + encodeURIComponent(st.sessionId) + '/lists', undefined, 3000)
|
|
318
|
+
if (!r) return { text: 'Leftoff: the daemon isn\'t running. From a terminal: leftoff todo done <list> ' + (a.numbers.join(' ') || a.text) }
|
|
319
|
+
const open = ((r.json && r.json.lists) || []).filter((l) => !l.archived)
|
|
320
|
+
if (!open.length) return { text: 'Leftoff: no list saved from this session yet. /cp-todo saves the checklist Claude just wrote; for another list: /cp-done 3 in <list>.' }
|
|
321
|
+
ref = open[0].ref // the newest one
|
|
322
|
+
}
|
|
323
|
+
const body = a.numbers.length ? { [a.undo ? 'undone' : 'done']: a.numbers } : { text: a.text, undo: a.undo }
|
|
324
|
+
const r = await api($, 'POST', '/v1/lists/' + encodeURIComponent(ref).replace('%2F', '/') + '/change', body, 4000)
|
|
325
|
+
if (!r) return { text: 'Leftoff: the daemon isn\'t running. From a terminal: leftoff todo done ' + ref + ' ' + (a.numbers.join(' ') || a.text) }
|
|
326
|
+
if (!r.ok || !r.json || !r.json.list) {
|
|
327
|
+
// a list named in the command: let the daemon resolve names, not just ids
|
|
328
|
+
if (a.list && r.status === 404) {
|
|
329
|
+
const res = await api($, 'GET', '/v1/lists/resolve?ref=' + encodeURIComponent(a.list), undefined, 3000)
|
|
330
|
+
if (res && res.ok && res.json && res.json.list && res.json.list.ref !== ref) return tickTodo($, (a.undo ? 'undo ' : '') + (a.numbers.join(' ') || a.text) + ' in ' + res.json.list.ref)
|
|
331
|
+
}
|
|
332
|
+
return { text: 'Leftoff: ' + ((r.json && r.json.error && r.json.error.message) || 'couldn\'t change the list (' + r.status + ')') }
|
|
333
|
+
}
|
|
334
|
+
const l = r.json.list
|
|
335
|
+
st.listPending = true
|
|
336
|
+
const hit = a.numbers.length ? l.items.filter((x) => a.numbers.includes(x.n)) : []
|
|
337
|
+
const line = (a.undo ? '↺ ' : '✓ ') + (hit.length ? hit.map((x) => '#' + x.n + ' ' + clip(x.text, 80)).join('; ') : 'ticked') + (a.undo ? ' (unticked)' : '') + ' · ' + l.name + ' ' + l.progress
|
|
338
|
+
const missing = r.json.missing && r.json.missing.length ? '\n(no item ' + r.json.missing.join(', ') + ' on that list)' : ''
|
|
339
|
+
return { text: line + missing + (l.done === l.total ? '\nThat\'s the whole list. 🎉' : '') }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// /cp-catchup [window] [repo ...|here|all] (v0.5.0): the daemon reads the
|
|
343
|
+
// folder and assembles the recap; with an in-session provider (auto, claude,
|
|
344
|
+
// anthropic) this session's own model writes the summary on top, otherwise the
|
|
345
|
+
// daemon's summarizer (or its no-model summary) does. Saved to catchups/ like
|
|
346
|
+
// the board's and the CLI's. -> { text } shown in the session.
|
|
347
|
+
async function makeCatchup($, args) {
|
|
348
|
+
if (st.internal) return { text: 'Leftoff: not in an internal session.' }
|
|
349
|
+
const a = parseCatchupArgs(args)
|
|
350
|
+
const repos = a.here ? [((st.record && st.record.repo && st.record.repo.root) || (st.record && st.record.cwd) || '')].filter(Boolean) : a.repos
|
|
351
|
+
const req = { since: a.since || undefined, repos }
|
|
352
|
+
const prep = await api($, 'POST', '/v1/catchups/prepare', req, 15000)
|
|
353
|
+
if (!prep) return { text: 'Leftoff: the daemon isn\'t running, so there\'s nothing to read. Start it with `leftoff serve` (or run `leftoff catchup` in a terminal; it reads the folder directly).' }
|
|
354
|
+
if (!prep.ok) return { text: 'Leftoff: ' + ((prep.json && prep.json.error && prep.json.error.message) || 'catch-up failed (' + prep.status + ')') }
|
|
355
|
+
const p = prep.json || {}
|
|
356
|
+
let summary
|
|
357
|
+
if (p.prompt) {
|
|
358
|
+
const r = await callModel($, p.prompt)
|
|
359
|
+
const bullets = r && parseBulletList(r.text, 6)
|
|
360
|
+
if (bullets) summary = { bullets, provider: 'claude-in-session', model: r.model || null }
|
|
361
|
+
}
|
|
362
|
+
const made = await api($, 'POST', '/v1/catchups', { ...req, to: p.window && p.window.to, summary }, summary ? 15000 : 90000)
|
|
363
|
+
if (!made || !made.ok) return { text: 'Leftoff: catch-up failed' + (made && made.json && made.json.error ? ': ' + made.json.error.message : '') }
|
|
364
|
+
return { text: formatCatchupText(made.json) }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function formatCatchupText(c) {
|
|
368
|
+
const r = c.recap || {}, w = r.window || {}
|
|
369
|
+
const lines = ['Catch me up · ' + (w.label || '') + ' · ' + (r.sessions || []).length + ' session' + ((r.sessions || []).length === 1 ? '' : 's') + ' in ' + (r.repos || []).length + ' repo' + ((r.repos || []).length === 1 ? '' : 's'), '']
|
|
370
|
+
for (const b of (c.summary && c.summary.bullets) || []) lines.push('• ' + b)
|
|
371
|
+
const needs = r.needsYou || []
|
|
372
|
+
if (needs.length) { lines.push('', 'Needs you:'); for (const n of needs.slice(0, 6)) lines.push(' ! ' + n.title + ' (' + n.repo + ') — ' + clip(n.needsYou, 140)) }
|
|
373
|
+
const rest = (r.sessions || []).filter((x) => !x.needsYou).slice(0, 8)
|
|
374
|
+
if (rest.length) { lines.push('', 'Where each one stopped:'); for (const x of rest) lines.push(' · ' + x.title + ' (' + x.repo + ', ' + x.state + ')' + (x.next ? ' — Next: ' + clip(x.next, 120) : '')) }
|
|
375
|
+
const open = r.stillOpen || []
|
|
376
|
+
if (open.length) { lines.push('', 'Still open:'); for (const l of open.slice(0, 6)) lines.push(' ☐ ' + l.name + ' (' + l.repo + ', ' + l.done + '/' + l.total + ')' + (l.next && l.next[0] ? ' — next: #' + l.next[0].n + ' ' + clip(l.next[0].text, 100) : '')) }
|
|
377
|
+
if (c.path) lines.push('', 'Saved ' + c.path + ' · the full page: /leftoff open → Catch me up')
|
|
378
|
+
return lines.join('\n')
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Run the CLI: `leftoff`, or `checkpoints` (its pre-0.4.5 name, still installed
|
|
382
|
+
// as an alias) when `leftoff` isn't on PATH yet. -> the run result, or null.
|
|
383
|
+
async function runCli($, args, opts) {
|
|
384
|
+
for (const bin of ['leftoff', 'checkpoints']) {
|
|
385
|
+
try {
|
|
386
|
+
const r = opts ? await $.process.run([bin, ...args], opts) : await $.process.run([bin, ...args])
|
|
387
|
+
if (r && r.exitCode !== 127) return r
|
|
388
|
+
} catch {}
|
|
389
|
+
}
|
|
390
|
+
return null
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Open the web board. `leftoff open --print` knows the token, starts the
|
|
394
|
+
// daemon if it's down and picks leftoff.localhost or 127.0.0.1; then the OS
|
|
395
|
+
// opener (macOS `open`, Linux `xdg-open`) shows it. Without the CLI on PATH it
|
|
396
|
+
// opens the 127.0.0.1 URL. The URL is always toasted too, so it can be clicked
|
|
397
|
+
// or copied when no browser opens (SSH, containers).
|
|
398
|
+
// /leftoff (and /checkpoints): the pane, or `open` for the web board.
|
|
399
|
+
async function paneCommand($, args) {
|
|
400
|
+
if (/^\s*(open|board|ui)\s*$/i.test(String(args || ''))) { const url = await openBoard($); return { text: 'Board: ' + url } }
|
|
401
|
+
if (st.paneTimer) { st.paneTimer.cancel(); st.paneTimer = null }
|
|
402
|
+
await refreshPane($)
|
|
403
|
+
let surfaces = []
|
|
404
|
+
try { surfaces = await $.session.surfaces() } catch {}
|
|
405
|
+
if (!surfaces || !surfaces.length) return { text: textList() } // VS Code extension and -p: no panes
|
|
406
|
+
await $.ui.open({ id: PANE_ID, title: 'Leftoff', closeOnEscape: true })
|
|
407
|
+
if (st.paneTimer) st.paneTimer.cancel()
|
|
408
|
+
st.paneTimer = $.clock.every(15000, () => { refreshPane($).catch(() => {}) })
|
|
409
|
+
return {}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function openBoard($) {
|
|
413
|
+
let url = 'http://127.0.0.1:' + st.port + '/'
|
|
414
|
+
try {
|
|
415
|
+
const r = await runCli($, ['open', '--print'])
|
|
416
|
+
const u = String((r && r.stdout) || '').trim().split('\n').pop()
|
|
417
|
+
if (r && r.exitCode === 0 && /^http:\/\/[\w.[\]:-]+\/$/.test(u)) url = u
|
|
418
|
+
} catch {}
|
|
419
|
+
let opened = false
|
|
420
|
+
for (const opener of ['open', 'xdg-open']) {
|
|
421
|
+
try { const r = await $.process.run([opener, url]); if (r && r.exitCode === 0) { opened = true; break } } catch {}
|
|
422
|
+
}
|
|
423
|
+
$.ui.toast((opened ? 'Opened ' : 'Board: ') + url, { timeoutMs: 10000 })
|
|
424
|
+
return url
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---------------------------------------------------------------- where this session runs (v0.4)
|
|
428
|
+
|
|
429
|
+
// One shell call: our parent is Claude Code's process (checked on 2.1.295:
|
|
430
|
+
// $.process.run's child has Claude Code as its direct parent). If a wrapper
|
|
431
|
+
// sits between, walk up to the first process named like claude.
|
|
432
|
+
const PROC_SCRIPT = 'p=$PPID; i=0; while [ "$i" -lt 4 ]; do c=$(LC_ALL=C ps -o comm= -p "$p" 2>/dev/null); case "$c" in *claude*|*Claude*) break;; esac; q=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); [ -z "$q" ] || [ "$q" -le 1 ] && { p=$PPID; break; }; p=$q; i=$((i+1)); done; [ "$i" -ge 4 ] && p=$PPID; set -- $(LC_ALL=C ps -o tty=,etime= -p "$p" 2>/dev/null); echo "$p ${1:-?} ${2:-}"'
|
|
433
|
+
|
|
434
|
+
async function probeProcess($) {
|
|
435
|
+
try {
|
|
436
|
+
const r = await $.process.run(['sh', '-c', PROC_SCRIPT], { timeoutMs: 3000 })
|
|
437
|
+
if (r && r.exitCode === 0) return parseProcLine(String(r.stdout || '').trim().split('\n').pop(), Date.now())
|
|
438
|
+
} catch {}
|
|
439
|
+
return null
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// $.env.get takes literal names only (static analysis lists what a mod reads).
|
|
443
|
+
async function readTermEnv($) {
|
|
444
|
+
const env = {}
|
|
445
|
+
const put = (k, v) => { if (v) env[k] = String(v) }
|
|
446
|
+
try {
|
|
447
|
+
put('TERM_PROGRAM', await $.env.get('TERM_PROGRAM'))
|
|
448
|
+
put('TERM_PROGRAM_VERSION', await $.env.get('TERM_PROGRAM_VERSION'))
|
|
449
|
+
put('TERM', await $.env.get('TERM'))
|
|
450
|
+
put('GHOSTTY_RESOURCES_DIR', await $.env.get('GHOSTTY_RESOURCES_DIR'))
|
|
451
|
+
put('ITERM_SESSION_ID', await $.env.get('ITERM_SESSION_ID'))
|
|
452
|
+
put('WEZTERM_PANE', await $.env.get('WEZTERM_PANE'))
|
|
453
|
+
put('WEZTERM_UNIX_SOCKET', await $.env.get('WEZTERM_UNIX_SOCKET'))
|
|
454
|
+
put('KITTY_WINDOW_ID', await $.env.get('KITTY_WINDOW_ID'))
|
|
455
|
+
put('KITTY_LISTEN_ON', await $.env.get('KITTY_LISTEN_ON'))
|
|
456
|
+
put('TMUX', await $.env.get('TMUX'))
|
|
457
|
+
put('TMUX_PANE', await $.env.get('TMUX_PANE'))
|
|
458
|
+
put('TERM_SESSION_ID', await $.env.get('TERM_SESSION_ID'))
|
|
459
|
+
st.titleOff = !!(await $.env.get('CLAUDE_CODE_DISABLE_TERMINAL_TITLE'))
|
|
460
|
+
} catch {}
|
|
461
|
+
return env
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// The tab title: the session's name ("<name> · cp:<id>" only when the daemon
|
|
465
|
+
// says Go needs the marker, v0.5.5). Claude Code draws the terminal title
|
|
466
|
+
// itself (✳ / ◐ + the session's name, repainted about once a second while it
|
|
467
|
+
// works), so a raw OSC 2 write is painted over almost at once. Instead the mod
|
|
468
|
+
// names the session with Claude Code's own /rename and Claude Code keeps the
|
|
469
|
+
// tab titled. (Claude Code's terminalTitleFromRename setting has to stay on,
|
|
470
|
+
// which it is by default.) With CLAUDE_CODE_DISABLE_TERMINAL_TITLE set, Claude
|
|
471
|
+
// Code draws nothing, so the mod writes OSC 2 to the session's tty instead.
|
|
472
|
+
function tabTitleOn() { return st.cfg.tabTitle !== false && !st.internal && st.surface !== 'headless' }
|
|
473
|
+
|
|
474
|
+
function desiredTabTitle(name) {
|
|
475
|
+
const repo = st.record ? repoName(st.record) : null
|
|
476
|
+
return formatTabTitle(st.cfg.tabTitleFormat, { name: name || (st.record && st.record.title) || null, id: st.sessionId, repo, marker: !!st.tabMarker })
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function applyTabTitle($, name, { force = false, current = null } = {}) {
|
|
480
|
+
if (!tabTitleOn() || !st.sessionId) return null
|
|
481
|
+
const title = desiredTabTitle(name)
|
|
482
|
+
if (current && String(current).trim() === title && !st.titleOff) { st.tabTitle = title; return null } // resumed: already named
|
|
483
|
+
if (!title || (!force && title === st.tabTitle)) return null
|
|
484
|
+
st.tabTitle = title
|
|
485
|
+
if (st.titleOff) {
|
|
486
|
+
const tty = st.proc && st.proc.tty
|
|
487
|
+
if (!tty) return null
|
|
488
|
+
try { await $.process.run(['sh', '-c', 'printf "\\033]2;%s\\007" "$1" > "$2"', 'sh', title, tty], { timeoutMs: 2000 }) } catch {}
|
|
489
|
+
return title
|
|
490
|
+
}
|
|
491
|
+
try { await $.command.run({ command: 'rename', args: title }) } catch { st.tabTitle = null }
|
|
492
|
+
return title
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// A title the daemon or a hook payload reports: adopt it for the tab when it
|
|
496
|
+
// isn't our own label coming back.
|
|
497
|
+
async function noteTitle($, title) {
|
|
498
|
+
if (!title || parseTabTitle(title).id) return
|
|
499
|
+
if (!st.record) st.record = {}
|
|
500
|
+
st.record.title = title
|
|
501
|
+
if (!st.record.inTurn) await applyTabTitle($, title)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Providers that use the session's own model. Any other provider (ollama,
|
|
505
|
+
// ollama-cloud, openai-compatible, heuristic) is the daemon's job: the mod hands
|
|
506
|
+
// the work to the daemon instead of calling $.model.complete.
|
|
507
|
+
const IN_SESSION_PROVIDERS = ['auto', 'claude', 'anthropic', 'api']
|
|
508
|
+
|
|
509
|
+
async function callModel($, ctx) {
|
|
510
|
+
const s = st.cfg.summarizer || {}
|
|
511
|
+
if (!IN_SESSION_PROVIDERS.includes(String(s.provider || s.mode || 'auto').toLowerCase())) return null
|
|
512
|
+
const model = ['haiku', 'sonnet', 'opus'].includes(s.model) || !s.model ? (s.model || 'haiku') : s.model
|
|
513
|
+
try {
|
|
514
|
+
const r = await raceTimeout($.model.complete({ model, system: ctx.system, prompt: ctx.prompt, maxTokens: ctx.maxTokens || 300 }), MODEL_TIMEOUT_MS, $)
|
|
515
|
+
if (!r || r.timedOut) return null
|
|
516
|
+
// The types say complete() resolves to the reply's text; the spec sketch
|
|
517
|
+
// mentions { isAnswered }. Accept both.
|
|
518
|
+
if (typeof r === 'string') return { text: r, model }
|
|
519
|
+
if (r.isAnswered === false) return null
|
|
520
|
+
return { text: r.text || '', model }
|
|
521
|
+
} catch {
|
|
522
|
+
return null
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
async function writeCheckpointDirect($, cp) {
|
|
527
|
+
let folder = null
|
|
528
|
+
const suffix = '_' + id8(st.sessionId)
|
|
529
|
+
const mine = st.root + '/sessions/' + st.machine
|
|
530
|
+
try {
|
|
531
|
+
const entries = await $.fs.list(mine)
|
|
532
|
+
const hit = entries.find((x) => x.kind === 'directory' && x.name.endsWith(suffix))
|
|
533
|
+
if (hit) folder = hit.name
|
|
534
|
+
} catch {}
|
|
535
|
+
if (!folder) folder = sessionFolderName({ ...st.record, id: st.sessionId, startedAt: st.record.startedAt || cp.createdAt })
|
|
536
|
+
for (let n = 0; n < 20; n++) {
|
|
537
|
+
const file = mine + '/' + folder + '/checkpoints/' + checkpointFileName(cp.createdAt, n)
|
|
538
|
+
let taken = false
|
|
539
|
+
try { taken = await $.fs.exists(file) } catch {}
|
|
540
|
+
if (taken) continue
|
|
541
|
+
const rec = { ...cp, id: checkpointId(st.sessionId, cp.createdAt, n) }
|
|
542
|
+
await $.fs.write(file, renderCheckpointMd(rec))
|
|
543
|
+
return rec
|
|
544
|
+
}
|
|
545
|
+
return null
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// length: 'short' | 'informative' | 'long' for this checkpoint only (/cp --short,
|
|
549
|
+
// /cp --long); null = config summary.length.
|
|
550
|
+
async function makeCheckpoint($, trigger, userNote, length) {
|
|
551
|
+
if (st.internal || st.checkpointing || !st.sessionId) return null
|
|
552
|
+
st.checkpointing = true
|
|
553
|
+
try {
|
|
554
|
+
const record = { ...(st.record || {}), id: st.sessionId }
|
|
555
|
+
let messages = []
|
|
556
|
+
try { messages = await $.session.messages() } catch {}
|
|
557
|
+
const ctx = buildCheckpointPrompt({ session: record, previous: st.lastCheckpoint, messages, cfg: st.cfg, userNote, length })
|
|
558
|
+
const reply = await callModel($, ctx)
|
|
559
|
+
let parsed = reply ? parseCheckpointReply(reply.text) : null
|
|
560
|
+
let generator = reply ? { by: 'mod', model: reply.model } : null
|
|
561
|
+
if (!parsed) {
|
|
562
|
+
// No model answer: let the daemon try its own summarizer, if it's there.
|
|
563
|
+
const r = await api($, 'POST', '/v1/sessions/' + encodeURIComponent(st.sessionId) + '/checkpoints', { trigger, generate: true, userNote: userNote || undefined, length: ctx.length }, MODEL_TIMEOUT_MS * 3)
|
|
564
|
+
if (r && r.ok && r.json) return afterCheckpoint($, r.json, trigger)
|
|
565
|
+
parsed = heuristicCheckpoint(messages, record, { length: ctx.length })
|
|
566
|
+
generator = { by: 'heuristic' }
|
|
567
|
+
}
|
|
568
|
+
const cp = {
|
|
569
|
+
sessionId: st.sessionId, createdAt: nowIso(), trigger, machine: st.machine,
|
|
570
|
+
sessionTitle: record.title || null, repo: record.repo ? repoName(record) : null, branch: (record.repo && record.repo.branch) || null, cwd: record.cwd || null,
|
|
571
|
+
doing: parsed.doing, stoppedAt: parsed.stoppedAt, next: parsed.next, openQuestion: parsed.openQuestion || null,
|
|
572
|
+
userNote: userNote || null, filesTouched: ctx.filesTouched, turnsSinceLast: record.turnsSinceCheckpoint || 0,
|
|
573
|
+
parseOk: parsed.parseOk, generator, length: ctx.length,
|
|
574
|
+
decisions: parsed.decisions || [], questions: parsed.questions || [], files: parsed.files || [],
|
|
575
|
+
didntWork: parsed.didntWork || [], recap: parsed.recap || '', nextSteps: parsed.nextSteps || [],
|
|
576
|
+
}
|
|
577
|
+
const r = await api($, 'POST', '/v1/sessions/' + encodeURIComponent(st.sessionId) + '/checkpoints', cp, 2000)
|
|
578
|
+
if (r && r.ok && r.json) return afterCheckpoint($, r.json, trigger)
|
|
579
|
+
const written = await writeCheckpointDirect($, cp)
|
|
580
|
+
return afterCheckpoint($, written || cp, trigger)
|
|
581
|
+
} finally {
|
|
582
|
+
st.checkpointing = false
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function afterCheckpoint($, cp, trigger) {
|
|
587
|
+
st.lastCheckpoint = cp
|
|
588
|
+
if (st.record) {
|
|
589
|
+
st.record.turnsSinceCheckpoint = 0
|
|
590
|
+
st.record.lastCheckpointAt = cp.createdAt || nowIso()
|
|
591
|
+
if (trigger === 'dormant') st.record.dormantStretch = st.record.lastActivityAt
|
|
592
|
+
}
|
|
593
|
+
$.ui.toast('Checkpoint saved · Next: ' + clip(cp.next, 120), { timeoutMs: 6000 })
|
|
594
|
+
$.ui.invalidate('ui.render')
|
|
595
|
+
return cp
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async function nameSession($, args) {
|
|
599
|
+
const title = String(args || '').trim()
|
|
600
|
+
if (!title) { $.ui.toast('Usage: /' + st.nameCommand + ' <title>'); return {} }
|
|
601
|
+
if (tabTitleOn()) {
|
|
602
|
+
if (!st.record) st.record = {}
|
|
603
|
+
st.record.title = title
|
|
604
|
+
await applyTabTitle($, title, { force: true }) // runs /rename "<title>" (plus " · cp:<id>" when Go needs it)
|
|
605
|
+
} else {
|
|
606
|
+
try { await $.command.run({ command: 'rename', args: title }) } catch {}
|
|
607
|
+
}
|
|
608
|
+
await sendOrSpool($, 'title_changed', { title, titleSource: 'custom' })
|
|
609
|
+
$.ui.toast('Named this session “' + clip(title, 60) + '”')
|
|
610
|
+
return {}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function refreshPane($) {
|
|
614
|
+
if (st.paneTimer) {
|
|
615
|
+
// Stop refreshing once the person has closed the pane.
|
|
616
|
+
let open = true
|
|
617
|
+
try { open = (await $.ui.panes()).some((p) => p.id === PANE_ID) } catch {}
|
|
618
|
+
if (!open) { st.paneTimer.cancel(); st.paneTimer = null; return }
|
|
619
|
+
}
|
|
620
|
+
const r = await api($, 'GET', '/v1/sessions?status=active,waiting,background,needs_input,dormant,stale&limit=30', undefined, 1500)
|
|
621
|
+
if (r && r.ok && r.json) {
|
|
622
|
+
// Empty sessions (no prompt yet, v0.4.8) stay off the pane, except this one.
|
|
623
|
+
st.paneRows = r.json.sessions.filter((s) => !s.empty || s.id === st.sessionId).map((s) => ({
|
|
624
|
+
id: s.id, title: s.title || '(untitled)', status: s.status, repo: s.repoLabel || s.repoName, where: (s.repoLabel || s.repoName) + (s.repo && s.repo.branch ? ' · ' + s.repo.branch : ''),
|
|
625
|
+
idleSec: s.idleSec, next: s.latestCheckpoint ? s.latestCheckpoint.next : '', resume: s.resumeCommand,
|
|
626
|
+
live: s.live || 'unknown', canGo: !!(s.jump && s.jump.supported), goWhy: (s.jump && s.jump.reason) || '',
|
|
627
|
+
needs: s.needsYou && s.needsYou.blocking ? s.needsYou : null,
|
|
628
|
+
}))
|
|
629
|
+
st.paneNeeds = st.paneRows.filter((x) => x.needs).length
|
|
630
|
+
st.paneNote = 'Board: http://127.0.0.1:' + st.port + '/ · b opens it'
|
|
631
|
+
// One extra line when the daemon's daily check found a newer version.
|
|
632
|
+
const h = await api($, 'GET', '/v1/health', undefined, 1000)
|
|
633
|
+
const u = h && h.ok && h.json && h.json.update
|
|
634
|
+
if (u && u.available) st.paneNote += ' · update available' + (u.remoteVersion ? ' (v' + u.remoteVersion + ')' : '') + ': run leftoff upgrade'
|
|
635
|
+
} else {
|
|
636
|
+
const rows = []
|
|
637
|
+
try {
|
|
638
|
+
for (const k of await $.store.keys()) {
|
|
639
|
+
if (!String(k).startsWith('session:')) continue
|
|
640
|
+
const s = await $.store.get(k)
|
|
641
|
+
if (!s || s.endedAt) continue
|
|
642
|
+
rows.push({ id: s.id, title: s.title || '(untitled)', status: 'unknown', repo: s.repo ? (s.repo.name || '') : '', where: s.repo ? (s.repo.name || '') : '', idleSec: null, next: s.next || '', resume: resumeCommand(s) })
|
|
643
|
+
}
|
|
644
|
+
} catch {}
|
|
645
|
+
st.paneRows = rows
|
|
646
|
+
st.paneNeeds = 0
|
|
647
|
+
st.paneNote = 'Daemon offline: showing the local registry. Start it with: leftoff serve'
|
|
648
|
+
}
|
|
649
|
+
st.paneRows.sort((a, b) => (b.id === st.sessionId) - (a.id === st.sessionId))
|
|
650
|
+
st.paneAll = st.paneRows
|
|
651
|
+
applyPaneRepo()
|
|
652
|
+
$.ui.invalidate('ui.render')
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// The pane's repo filter (v0.4.2): `f` steps through the repos with open
|
|
656
|
+
// sessions (most recent first), then back to all of them.
|
|
657
|
+
function paneRepos() {
|
|
658
|
+
const seen = []
|
|
659
|
+
for (const r of st.paneAll || []) if (r.repo && !seen.includes(r.repo)) seen.push(r.repo)
|
|
660
|
+
return seen
|
|
661
|
+
}
|
|
662
|
+
function applyPaneRepo() {
|
|
663
|
+
const all = st.paneAll || st.paneRows
|
|
664
|
+
if (st.paneRepo && !all.some((r) => r.repo === st.paneRepo)) st.paneRepo = null
|
|
665
|
+
st.paneRows = st.paneRepo ? all.filter((r) => r.repo === st.paneRepo) : all
|
|
666
|
+
if (st.paneSel >= st.paneRows.length) st.paneSel = Math.max(0, st.paneRows.length - 1)
|
|
667
|
+
}
|
|
668
|
+
function cyclePaneRepo() {
|
|
669
|
+
const repos = paneRepos()
|
|
670
|
+
const i = st.paneRepo ? repos.indexOf(st.paneRepo) : -1
|
|
671
|
+
st.paneRepo = i + 1 < repos.length ? repos[i + 1] : null
|
|
672
|
+
st.paneSel = 0
|
|
673
|
+
applyPaneRepo()
|
|
674
|
+
return st.paneRepo
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Go: bring a session's terminal tab to the front. The CLI runs the jump
|
|
678
|
+
// (so macOS asks the terminal, not the daemon, for Automation permission);
|
|
679
|
+
// without the CLI on PATH the daemon does it.
|
|
680
|
+
async function goTo($, row) {
|
|
681
|
+
if (!row) return
|
|
682
|
+
if (row.id === st.sessionId) { $.ui.toast('That’s this session'); return }
|
|
683
|
+
if (!row.canGo) { $.ui.toast('Can’t jump to “' + clip(row.title, 40) + '”' + (row.goWhy ? ': ' + row.goWhy : ''), { timeoutMs: 8000 }); return }
|
|
684
|
+
// Feedback at once: a slow Go should never look like nothing happened.
|
|
685
|
+
$.ui.toast('Switching to “' + clip(row.title, 40) + '”…', { timeoutMs: 20000 })
|
|
686
|
+
const t0 = Date.now()
|
|
687
|
+
let msg = null
|
|
688
|
+
try {
|
|
689
|
+
const r = await runCli($, ['go', '--id=' + row.id, '--plain'], { timeoutMs: 30000 })
|
|
690
|
+
if (r && (r.exitCode === 0 || r.exitCode === 3)) msg = String(r.stdout || '').trim().split('\n').pop()
|
|
691
|
+
} catch {}
|
|
692
|
+
if (!msg) {
|
|
693
|
+
const r = await api($, 'POST', '/v1/sessions/' + encodeURIComponent(row.id) + '/go', {}, 30000)
|
|
694
|
+
msg = r && r.json ? (r.json.message || (r.json.error && r.json.error.message) || null) : 'Couldn’t reach the daemon'
|
|
695
|
+
if (msg && r && r.json && r.json.elapsedMs != null) msg += ' in ' + (r.json.elapsedMs / 1000).toFixed(1) + ' s'
|
|
696
|
+
}
|
|
697
|
+
if (!msg) msg = 'Switched in ' + ((Date.now() - t0) / 1000).toFixed(1) + ' s'
|
|
698
|
+
$.ui.toast(msg, { timeoutMs: 8000 })
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function needsLine(n) {
|
|
702
|
+
if (n.ask && n.ask.describe) return n.ask.describe.verb + ': ' + n.ask.describe.detail
|
|
703
|
+
if (n.ask && n.ask.questions && n.ask.questions[0]) return n.ask.questions[0].question
|
|
704
|
+
return n.message || n.question || 'waiting'
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function idleLabel(sec) {
|
|
708
|
+
if (sec == null) return ''
|
|
709
|
+
if (sec < 60) return 'now'
|
|
710
|
+
if (sec < 3600) return Math.round(sec / 60) + 'm'
|
|
711
|
+
return Math.round(sec / 3600) + 'h'
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const DOT = { active: 'green', waiting: 'yellow', background: 'cyan', needs_input: 'red', dormant: 'gray', stale: 'gray', ended: 'gray' }
|
|
715
|
+
|
|
716
|
+
function textList() {
|
|
717
|
+
if (!st.paneRows.length) return 'No open sessions. ' + st.paneNote
|
|
718
|
+
return st.paneRows.map((r) => (r.id === st.sessionId ? '▶ ' : ' ') + r.status.padEnd(11) + ' ' + r.title + ' (' + r.where + ')' + (r.next ? '\n Next: ' + clip(r.next, 100) : '')).join('\n') + '\n' + st.paneNote
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
// ---------------------------------------------------------------- Needs you (v0.4.4)
|
|
723
|
+
|
|
724
|
+
// Claude Code shows its permission dialog (or AskUserQuestion's picker) and runs
|
|
725
|
+
// this hook beside it; whichever answers first wins (checked on 2.1.295). We put
|
|
726
|
+
// the request on the board and long-poll the daemon. Time spent inside
|
|
727
|
+
// $.http.fetch doesn't count against the hook's 10 s budget. A board answer is
|
|
728
|
+
// returned as the decision; anything else returns what came back from next(e),
|
|
729
|
+
// so the terminal decides. Nothing is ever allowed without a person's click.
|
|
730
|
+
const ASK_POLL_MS = 20000
|
|
731
|
+
const ASK_MAX_MS = 9.5 * 60 * 1000
|
|
732
|
+
|
|
733
|
+
async function permissionHook($, e, next) {
|
|
734
|
+
const base = await next(e)
|
|
735
|
+
if (st.internal || !st.sessionId || (base && base.decision)) return base
|
|
736
|
+
const opened = await api($, 'POST', '/v1/asks', {
|
|
737
|
+
sessionId: e.session_id || st.sessionId, tool: e.tool_name, input: e.tool_input, suggestions: e.permission_suggestions,
|
|
738
|
+
capture: 'mod', cwd: e.cwd, permissionMode: e.permission_mode,
|
|
739
|
+
}, 1500)
|
|
740
|
+
const id = opened && opened.ok && opened.json && opened.json.state === 'waiting' ? opened.json.id : null
|
|
741
|
+
if (!id) return base
|
|
742
|
+
const mine = { tool: e.tool_name, input: e.tool_input, done: false }
|
|
743
|
+
st.asks[id] = mine
|
|
744
|
+
let aborted = false
|
|
745
|
+
// Esc in the terminal abandons the event (next.signal aborts): tell the board.
|
|
746
|
+
try {
|
|
747
|
+
next.signal.addEventListener('abort', () => {
|
|
748
|
+
aborted = true
|
|
749
|
+
if (!mine.done) { mine.done = true; api($, 'POST', '/v1/asks/' + id + '/cancel', { reason: 'cancelled_in_terminal' }, 800).catch(() => {}) }
|
|
750
|
+
})
|
|
751
|
+
} catch {}
|
|
752
|
+
const t0 = Date.now()
|
|
753
|
+
try {
|
|
754
|
+
while (!mine.done && !aborted && Date.now() - t0 < ASK_MAX_MS) {
|
|
755
|
+
const r = await api($, 'GET', '/v1/asks/' + id + '/wait?ms=' + ASK_POLL_MS, undefined, ASK_POLL_MS + 5000)
|
|
756
|
+
if (!r || !r.ok || !r.json) break // daemon gone: the terminal decides
|
|
757
|
+
if (r.json.state === 'answered' && r.json.decision && !aborted && !mine.done) {
|
|
758
|
+
mine.done = true
|
|
759
|
+
return { ...(base || {}), decision: r.json.decision }
|
|
760
|
+
}
|
|
761
|
+
if (r.json.state !== 'waiting') break
|
|
762
|
+
}
|
|
763
|
+
} finally {
|
|
764
|
+
mine.done = true
|
|
765
|
+
delete st.asks[id]
|
|
766
|
+
}
|
|
767
|
+
return base
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// The tool ran: if the board was still showing its request, the terminal
|
|
771
|
+
// answered it first (Claude Code doesn't abandon our hook in that case).
|
|
772
|
+
async function toolRan($, e) {
|
|
773
|
+
const ids = Object.keys(st.asks).filter((id) => !st.asks[id].done && st.asks[id].tool === e.tool_name)
|
|
774
|
+
if (!ids.length) return
|
|
775
|
+
for (const id of ids) st.asks[id].done = true
|
|
776
|
+
await api($, 'POST', '/v1/asks/settle', { sessionId: e.session_id || st.sessionId, tool: e.tool_name, input: e.tool_input, reason: 'answered_in_terminal' }, 800)
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// Replies typed on the board: a long-poll the daemon answers with the next one,
|
|
780
|
+
// typed in with $.prompt.submit, which Claude Code runs once the session is idle
|
|
781
|
+
// (checked on 2.1.295: a submit during a turn waits for it to end).
|
|
782
|
+
function startReplyLoop($) {
|
|
783
|
+
if (st.replyLoop || st.internal || st.surface === 'headless') return
|
|
784
|
+
st.replyLoop = true
|
|
785
|
+
const loop = async () => {
|
|
786
|
+
let backoff = 0
|
|
787
|
+
while (st.replyLoop && st.sessionId) {
|
|
788
|
+
const sid = st.sessionId
|
|
789
|
+
const r = await api($, 'GET', '/v1/sessions/' + encodeURIComponent(sid) + '/replies/next?ms=' + ASK_POLL_MS, undefined, ASK_POLL_MS + 5000)
|
|
790
|
+
if (!r || !r.ok) {
|
|
791
|
+
// Daemon down: try again on the heartbeat rather than spinning.
|
|
792
|
+
st.replyLoop = false
|
|
793
|
+
return
|
|
794
|
+
}
|
|
795
|
+
const reply = r.json && r.json.reply
|
|
796
|
+
if (reply && reply.text && !String(reply.text).startsWith('/') && sid === st.sessionId) {
|
|
797
|
+
try {
|
|
798
|
+
await $.prompt.submit({ text: String(reply.text) })
|
|
799
|
+
} catch (err) {
|
|
800
|
+
try { $.ui.toast('Leftoff: couldn’t send the board’s reply: ' + String((err && err.message) || err).slice(0, 120)) } catch {}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
backoff = 0
|
|
804
|
+
}
|
|
805
|
+
st.replyLoop = false
|
|
806
|
+
}
|
|
807
|
+
loop().catch(() => { st.replyLoop = false })
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
export function register(on, options) {
|
|
811
|
+
on('session.start', async ($, e, next) => {
|
|
812
|
+
try {
|
|
813
|
+
if (await isInternal($)) { st.internal = true; return next(e) }
|
|
814
|
+
await loadSetup($)
|
|
815
|
+
st.sessionId = await $.session.id()
|
|
816
|
+
const cwd = await $.session.cwd()
|
|
817
|
+
const repo = await readRepo($, cwd)
|
|
818
|
+
let model = null
|
|
819
|
+
try { model = await $.session.model() } catch {}
|
|
820
|
+
let surface = null
|
|
821
|
+
try { const surfaces = await $.session.surfaces(); surface = surfaces && surfaces.length ? surfaces[0] : 'headless' } catch {}
|
|
822
|
+
st.record = null
|
|
823
|
+
// Where this session runs: Claude Code's pid + tty (for open/closed and
|
|
824
|
+
// Terminal.app) and the terminal's own ids (for Go).
|
|
825
|
+
st.proc = await probeProcess($)
|
|
826
|
+
const termEnv = await readTermEnv($)
|
|
827
|
+
st.term = detectTerminal(termEnv)
|
|
828
|
+
st.surface = surface
|
|
829
|
+
st.hello = { cwd, repo, model, surface, proc: st.proc ? { ...st.proc, source: 'mod' } : null, term: st.term }
|
|
830
|
+
await sendOrSpool($, 'session.opened', st.hello)
|
|
831
|
+
if (st.heartbeat) st.heartbeat.cancel()
|
|
832
|
+
st.heartbeat = $.clock.every(60000, () => { heartbeat($).catch(() => {}); startReplyLoop($) })
|
|
833
|
+
st.ready = true
|
|
834
|
+
startReplyLoop($)
|
|
835
|
+
} catch {}
|
|
836
|
+
// Commands last, each on its own: a taken name throws (spec §15 Q3), so a
|
|
837
|
+
// clash costs only that command, and registerCommands reports it.
|
|
838
|
+
await registerCommands($)
|
|
839
|
+
return next(e)
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
on('classic.SessionStart', async ($, e, next) => {
|
|
843
|
+
if (!st.internal && e.session_id) {
|
|
844
|
+
let parentSessionId
|
|
845
|
+
if (st.sessionId && e.session_id !== st.sessionId) {
|
|
846
|
+
// /clear, /resume or a fork: a new id in the same process (spec §15 Q4).
|
|
847
|
+
parentSessionId = st.sessionId
|
|
848
|
+
st.sessionId = e.session_id
|
|
849
|
+
st.record = { ...(st.record || {}), id: e.session_id, turns: 0, turnsSinceCheckpoint: 0, endedAt: null, title: null, titleSource: null, startedAt: null }
|
|
850
|
+
st.lastCheckpoint = null
|
|
851
|
+
st.titleSoon = false
|
|
852
|
+
st.daemonTitle = null
|
|
853
|
+
}
|
|
854
|
+
if (!st.sessionId) st.sessionId = e.session_id
|
|
855
|
+
await sendOrSpool($, 'session.started', {
|
|
856
|
+
source: e.source, transcriptPath: e.transcript_path, cwd: e.cwd, sessionTitle: e.session_title, model: e.model, parentSessionId,
|
|
857
|
+
// a new id in the same process (/clear, /resume) runs in the same tab
|
|
858
|
+
...(parentSessionId ? { proc: st.proc ? { ...st.proc, source: 'mod' } : null, term: st.term } : {}),
|
|
859
|
+
})
|
|
860
|
+
if (st.surface !== 'headless') {
|
|
861
|
+
const own = e.session_title ? parseTabTitle(e.session_title) : null
|
|
862
|
+
const name = own && !own.id ? own.name : (own && own.name) || st.daemonTitle || null
|
|
863
|
+
await applyTabTitle($, name, { force: !!parentSessionId, current: e.session_title || null })
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return next(e)
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
on('classic.UserPromptSubmit', async ($, e, next) => {
|
|
870
|
+
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
|
|
871
|
+
return next(e)
|
|
872
|
+
})
|
|
873
|
+
|
|
874
|
+
// v0.5.1: when this session's saved lists changed since Claude last heard
|
|
875
|
+
// (saved, ticked on the board, a resumed session), the prompt carries a
|
|
876
|
+
// context entry: the list, its numbers, and `leftoff todo done <list> <n>`.
|
|
877
|
+
// Context goes on the event before next(); on the result it would be too late.
|
|
878
|
+
on('prompt.submit', async ($, e, next) => {
|
|
879
|
+
const ctx = await listContextFor($)
|
|
880
|
+
return next(ctx ? { ...e, context: [...(Array.isArray(e.context) ? e.context : []), ctx] } : e)
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
on('turn.start', async ($, e, next) => {
|
|
884
|
+
await sendOrSpool($, 'turn_started', {})
|
|
885
|
+
return next(e)
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
on('turn.complete', async ($, e, next) => {
|
|
889
|
+
// classic.Stop reports a normal end with its details; Stop doesn't fire for
|
|
890
|
+
// an interrupted or failed turn, so those are reported here.
|
|
891
|
+
if (e.reason === 'aborted' || e.reason === 'error' || e.reason === 'refusal') await sendOrSpool($, 'turn_completed', { endReason: e.reason })
|
|
892
|
+
return next(e)
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
on('classic.Stop', async ($, e, next) => {
|
|
896
|
+
// v0.5.1: a checklist of 3+ in this reply (a cheap regex, no model) -> the
|
|
897
|
+
// board's card offers "Claude wrote a 7-step list · Save". Nothing is saved here.
|
|
898
|
+
const listHint = listHintFrom(e.last_assistant_message || '', { fallback: st.daemonTitle || null })
|
|
899
|
+
await sendOrSpool($, 'turn_completed', {
|
|
900
|
+
lastAssistantPreview: clip(e.last_assistant_message || '', 280),
|
|
901
|
+
question: trailingQuestion(e.last_assistant_message || ''),
|
|
902
|
+
...(listHint ? { listHint } : {}),
|
|
903
|
+
backgroundTasks: Array.isArray(e.background_tasks) ? e.background_tasks.length : 0,
|
|
904
|
+
sessionCrons: Array.isArray(e.session_crons) ? e.session_crons.length : 0,
|
|
905
|
+
})
|
|
906
|
+
// The daemon titles a session after its first turn (a few seconds of
|
|
907
|
+
// summarizer); name the tab once it has, without waiting for the minute heartbeat.
|
|
908
|
+
if (st.daemonTitle && st.surface !== 'headless') await noteTitle($, st.daemonTitle)
|
|
909
|
+
else if (!st.titleSoon && st.surface !== 'headless' && tabTitleOn()) {
|
|
910
|
+
st.titleSoon = true
|
|
911
|
+
$.clock.after(12000, () => { heartbeat($).catch(() => {}) })
|
|
912
|
+
}
|
|
913
|
+
return next(e)
|
|
914
|
+
})
|
|
915
|
+
|
|
916
|
+
on('classic.Notification', async ($, e, next) => {
|
|
917
|
+
await sendOrSpool($, 'notification', { notificationType: e.notification_type || null, message: clip(e.message || '', 200) })
|
|
918
|
+
return next(e)
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
on('classic.PermissionRequest', permissionHook)
|
|
922
|
+
|
|
923
|
+
on('classic.PostToolUse', async ($, e, next) => { await toolRan($, e); return next(e) })
|
|
924
|
+
|
|
925
|
+
on('classic.PostToolUseFailure', async ($, e, next) => { await toolRan($, e); return next(e) })
|
|
926
|
+
|
|
927
|
+
on('session.end', async ($, e, next) => {
|
|
928
|
+
// About 1.5 s shared by every SessionEnd hook: post and go. The daemon makes
|
|
929
|
+
// the final checkpoint later from transcript_path.
|
|
930
|
+
await sendOrSpool($, 'session.ended', { reason: e.reason }, END_TIMEOUT_MS)
|
|
931
|
+
if (e.reason !== 'clear' && e.reason !== 'resume' && st.heartbeat) { st.heartbeat.cancel(); st.heartbeat = null; st.replyLoop = false }
|
|
932
|
+
return next(e)
|
|
933
|
+
})
|
|
934
|
+
|
|
935
|
+
// No hooks on 'checkpoint' or 'name': those are aliases of the built-in
|
|
936
|
+
// /rewind and /rename, and a hook here would sit in front of them.
|
|
937
|
+
on('command.run', { command: 'cp' }, async ($, e) => checkpointCommand($, e.args))
|
|
938
|
+
|
|
939
|
+
on('command.run', { command: 'cp-save' }, async ($, e) => checkpointCommand($, e.args))
|
|
940
|
+
|
|
941
|
+
on('command.run', { command: 'cp-name' }, async ($, e) => nameSession($, e.args))
|
|
942
|
+
|
|
943
|
+
on('command.run', { command: 'leftoff' }, async ($, e) => paneCommand($, e.args))
|
|
944
|
+
on('command.run', { command: 'cp-catchup' }, async ($, e) => makeCatchup($, e.args))
|
|
945
|
+
on('command.run', { command: 'cp-todo' }, async ($, e) => saveTodo($, e.args))
|
|
946
|
+
on('command.run', { command: 'leftoff-todo' }, async ($, e) => saveTodo($, e.args))
|
|
947
|
+
on('command.run', { command: 'cp-done' }, async ($, e) => tickTodo($, e.args))
|
|
948
|
+
on('command.run', { command: 'leftoff-done' }, async ($, e) => tickTodo($, e.args))
|
|
949
|
+
on('command.run', { command: 'leftoff-catchup' }, async ($, e) => makeCatchup($, e.args))
|
|
950
|
+
|
|
951
|
+
on('command.run', { command: 'checkpoints' }, async ($, e) => paneCommand($, e.args))
|
|
952
|
+
|
|
953
|
+
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
|
|
954
|
+
if (e.requestId !== PANE_ID) return next(e)
|
|
955
|
+
const { Box, Text, Button } = $.ui.resolve(e)
|
|
956
|
+
const width = (e.props && e.props.bodyColumns) || 60
|
|
957
|
+
const rows = st.paneRows.map((r, i) => Box({
|
|
958
|
+
key: 'row-' + r.id,
|
|
959
|
+
flexDirection: 'column',
|
|
960
|
+
children: [
|
|
961
|
+
Box({ flexDirection: 'row', columnGap: 1, children: [
|
|
962
|
+
Text({ color: i === st.paneSel ? 'cyan' : 'gray', children: [i === st.paneSel ? '›' : ' '] }),
|
|
963
|
+
Text({ dimColor: true, children: [i < 9 ? String(i + 1) : ' '] }),
|
|
964
|
+
Text({ color: r.needs ? 'red' : r.live === 'closed' ? 'gray' : (DOT[r.status] || 'white'), children: [r.needs ? '!' : r.live === 'closed' ? '○' : '●'] }),
|
|
965
|
+
Text({ bold: r.id === st.sessionId, wrap: 'truncate-end', children: [clip(r.title, Math.max(10, width - 18))] }),
|
|
966
|
+
Text({ dimColor: true, children: [idleLabel(r.idleSec)] }),
|
|
967
|
+
] }),
|
|
968
|
+
Text({ dimColor: !r.needs, color: r.needs ? 'red' : undefined, wrap: 'truncate-end', children: [' ' + (r.needs ? 'Needs you: ' + needsLine(r.needs) : r.where + (r.next ? ' · Next: ' + r.next : ''))] }),
|
|
969
|
+
],
|
|
970
|
+
}))
|
|
971
|
+
return Box({
|
|
972
|
+
flexDirection: 'column',
|
|
973
|
+
children: [
|
|
974
|
+
...(st.paneNeeds ? [Text({ color: 'red', bold: true, children: [st.paneNeeds + (st.paneNeeds === 1 ? ' session needs you' : ' sessions need you') + ' · answer on the board (b) or Go'] })] : []),
|
|
975
|
+
...(rows.length ? rows : [Text({ dimColor: true, children: ['No open sessions yet.'] })]),
|
|
976
|
+
Text({ children: [' '] }),
|
|
977
|
+
Box({ flexDirection: 'row', columnGap: 2, children: [
|
|
978
|
+
Button({ key: 'up', label: '↑', hotkey: 'k', onPress: async () => { st.paneSel = Math.max(0, st.paneSel - 1); $.ui.invalidate('ui.render') } }),
|
|
979
|
+
Button({ key: 'down', label: '↓', hotkey: 'j', onPress: async () => { st.paneSel = Math.min(Math.max(0, st.paneRows.length - 1), st.paneSel + 1); $.ui.invalidate('ui.render') } }),
|
|
980
|
+
Button({ key: 'go', label: 'Go', hotkey: 'g', onPress: async () => { await goTo($, st.paneRows[st.paneSel]) } }),
|
|
981
|
+
...st.paneRows.slice(0, 9).map((r, i) => Button({ key: 'go' + (i + 1), label: String(i + 1), hotkey: String(i + 1), onPress: async () => { st.paneSel = i; await goTo($, r) } })),
|
|
982
|
+
] }),
|
|
983
|
+
Box({ flexDirection: 'row', columnGap: 2, children: [
|
|
984
|
+
Button({ key: 'cp', label: 'Checkpoint', hotkey: 'c', onPress: async () => { await makeCheckpoint($, 'manual', null) } }),
|
|
985
|
+
Button({ key: 'copy', label: 'Copy resume', hotkey: 'r', onPress: async () => {
|
|
986
|
+
const me = st.paneRows.find((r) => r.id === st.sessionId)
|
|
987
|
+
const cmd = me ? me.resume : resumeCommand({ id: st.sessionId, cwd: st.record && st.record.cwd })
|
|
988
|
+
try { await $.ui.copy(cmd); $.ui.toast('Copied: ' + cmd) } catch { $.ui.toast(cmd, { timeoutMs: 10000 }) }
|
|
989
|
+
} }),
|
|
990
|
+
Button({ key: 'ui', label: 'Open board', hotkey: 'b', onPress: async () => { await openBoard($) } }),
|
|
991
|
+
Button({ key: 'catchup', label: 'Catch me up', hotkey: 'u', onPress: async () => {
|
|
992
|
+
$.ui.toast('Catching you up…')
|
|
993
|
+
const r = await makeCatchup($, '')
|
|
994
|
+
$.ui.toast(String((r && r.text) || '').split('\n').filter(Boolean).slice(0, 3).join(' · '), { timeoutMs: 12000 })
|
|
995
|
+
} }),
|
|
996
|
+
Button({ key: 'repo', label: st.paneRepo ? 'Repo: ' + st.paneRepo : 'Repo: all', hotkey: 'f', onPress: async () => {
|
|
997
|
+
const now = cyclePaneRepo()
|
|
998
|
+
$.ui.toast(now ? 'Showing ' + now + ' (' + st.paneRows.length + ') · f for the next repo' : 'Showing all repos')
|
|
999
|
+
$.ui.invalidate('ui.render')
|
|
1000
|
+
} }),
|
|
1001
|
+
] }),
|
|
1002
|
+
Text({ dimColor: true, children: [st.paneNote] }),
|
|
1003
|
+
],
|
|
1004
|
+
})
|
|
1005
|
+
})
|
|
1006
|
+
}
|