@leftoff/cli 0.9.18-beta.1 → 0.9.18-beta.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -1
- package/bin/leftoff-statusline.js +9 -0
- package/bin/leftoff.js +787 -557
- package/bin/pty-host-main.js +66 -0
- package/docs/SECURITY.md +1 -1
- package/docs/macos-permissions.md +52 -0
- package/docs/test-plan.md +43 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +211 -13
- package/mod/hooks/register.js +196 -7
- package/mod/skills/walkthrough/SKILL.md +5 -3
- package/package.json +1 -1
- package/ui/app.css +279 -4
- package/ui/app.js +661 -53
- package/ui/ask.js +5 -3
- package/ui/chip-fade.js +1 -1
- package/ui/cp-fold.js +22 -0
- package/ui/decisions.js +245 -0
- package/ui/fs-side.js +29 -0
- package/ui/hook-fix.js +109 -0
- package/ui/live-status.js +68 -2
- package/ui/live-view.js +36 -5
- package/ui/prs.js +15 -5
- package/ui/pull.js +14 -1
- package/ui/semdiff.js +163 -20
- package/ui/session-catchup.js +18 -2
- package/ui/session-title.js +88 -0
- package/ui/sglyph.js +2 -2
- package/ui/slash-commands.js +89 -0
- package/ui/slash.js +82 -0
- package/ui/summary-examples.js +2 -2
- package/ui/tabs.js +10 -3
- package/ui/term.js +11 -3
- package/ui/testplan.js +162 -0
- package/ui/viewport-fit.js +73 -0
- package/ui/walk-pop.js +3 -0
- package/ui/what-it-did.js +12 -5
package/mod/hooks/register.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// each event is registered once per matcher.
|
|
15
15
|
import {
|
|
16
16
|
DEFAULT_CONFIG, DEFAULT_PORT, mergeConfig, resolveMachine, applyEvent, checkpointDue, buildCheckpointPrompt,
|
|
17
|
-
parseCheckpointReply, retryCheckpointPrompt, betterSalvage, heuristicCheckpoint, isEmptyCheckpoint, NOTHING_TO_CHECKPOINT, renderCheckpointMd, checkpointId, checkpointFileName,
|
|
17
|
+
parseCheckpointReply, retryCheckpointPrompt, mergeDecided, betterSalvage, heuristicCheckpoint, isEmptyCheckpoint, NOTHING_TO_CHECKPOINT, renderCheckpointMd, checkpointId, checkpointFileName,
|
|
18
18
|
sessionFolderName, id8, clip, toIso, resumeCommand, repoName,
|
|
19
19
|
shortId, formatTabTitle, parseTabTitle, tabRenameWaits, detectTerminal, parseProcLine, trailingQuestion,
|
|
20
20
|
parseCheckpointArgs, SUMMARY_LENGTHS, parseBulletList, parseCatchupArgs,
|
|
@@ -26,9 +26,13 @@ const PANE_ID = 'checkpoints'
|
|
|
26
26
|
const POST_TIMEOUT_MS = 800
|
|
27
27
|
const END_TIMEOUT_MS = 1000
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
|
+
const STOP_GRACE_MS = 2500 // 2026-11-03 how long a completed turn waits for its Stop before the mod reports the end itself
|
|
30
|
+
const SKIP_GRACE_MS = 4000 // 2026-11-03 how long a mod event waits for its classic twin before that classic event counts as passed over
|
|
31
|
+
const START_SKIP_GRACE_MS = 15000 // the same for SessionStart (a cold start can be slow)
|
|
32
|
+
const PERM_GRACE_MS = 1500 // how long a permission prompt tool.check saw waits for classic.PermissionRequest before the mod reports it
|
|
29
33
|
|
|
30
34
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.9.18-beta.
|
|
35
|
+
const MOD_VERSION = '0.9.18-beta.11' // keep in step with .claude-plugin/plugin.json
|
|
32
36
|
|
|
33
37
|
const st = {
|
|
34
38
|
internal: false,
|
|
@@ -69,6 +73,15 @@ const st = {
|
|
|
69
73
|
listSig: null, // v0.5.1: the signature of this session's lists Claude last heard about
|
|
70
74
|
listPending: false, // a list was saved or ticked here: tell Claude with the next prompt
|
|
71
75
|
paneNeeds: 0, // sessions that need you (the pane's top line)
|
|
76
|
+
turnStartAt: 0, // 2026-11-03 when this process's current turn started (turn.start)
|
|
77
|
+
stopAt: 0, // and when a Stop last came (classic.Stop)
|
|
78
|
+
// 2026-11-03 Claude Code 2.1.31x passes every classic.* event of a plugin you
|
|
79
|
+
// installed over when its built-in cc-plugin-sec-default is seated first (a
|
|
80
|
+
// Team or Enterprise plan, or managed settings): see SKIP_GRACE_MS below.
|
|
81
|
+
classicSeen: false, // a classic.* hook of this mod ran in this process
|
|
82
|
+
hooksSkipped: null, // the classic events we saw passed over, as last told to the daemon
|
|
83
|
+
permAt: 0, // when classic.PermissionRequest last ran
|
|
84
|
+
watchAsks: {}, // tool_use_id -> { tool, at, reported, done }: permission prompts tool.check saw
|
|
72
85
|
}
|
|
73
86
|
|
|
74
87
|
// Millisecond precision: the daemon dedupes events per (sessionId, type, at).
|
|
@@ -230,7 +243,7 @@ async function readRepo($, cwd) {
|
|
|
230
243
|
|
|
231
244
|
async function heartbeat($) {
|
|
232
245
|
if (st.internal || !st.sessionId) return
|
|
233
|
-
const res = await sendOrSpool($, 'heartbeat', { inTurn: !!(st.record && st.record.inTurn) })
|
|
246
|
+
const res = await sendOrSpool($, 'heartbeat', { inTurn: !!(st.record && st.record.inTurn), ...(st.hooksSkipped && st.hooksSkipped.length ? { hooksSkipped: st.hooksSkipped } : {}) })
|
|
234
247
|
if (res && !st.modHealthSent) await reportModHealth($)
|
|
235
248
|
if (res && st.daemonTitle) await noteTitle($, st.daemonTitle)
|
|
236
249
|
else if (res && st.tabTitle && !(st.record && st.record.inTurn)) await applyTabTitle($, null) // the marker came (it never goes: tabMarkerKept), or a rename that had to wait
|
|
@@ -243,6 +256,99 @@ async function heartbeat($) {
|
|
|
243
256
|
}
|
|
244
257
|
}
|
|
245
258
|
|
|
259
|
+
// 2026-11-03 a turn completed and no Stop came for it within STOP_GRACE_MS:
|
|
260
|
+
// report its end (once; nothing when this process doesn't hold a turn open).
|
|
261
|
+
async function stopFallback($, completedAt, reason) {
|
|
262
|
+
if (st.internal || !st.sessionId) return
|
|
263
|
+
if (st.stopAt && st.stopAt >= (st.turnStartAt || 0)) return // its Stop came
|
|
264
|
+
if (!(st.record && st.record.inTurn)) return
|
|
265
|
+
st.stopAt = Date.now()
|
|
266
|
+
await sendOrSpool($, 'turn_completed', { endReason: String(reason || 'end').slice(0, 40), settledBy: 'mod', completedAt: new Date(completedAt).toISOString() })
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------- passed-over classic hooks (2026-11-03)
|
|
270
|
+
//
|
|
271
|
+
// Claude Code 2.1.31x seats its built-in "cc-plugin-sec-default" outermost on
|
|
272
|
+
// a Team or Enterprise plan or a machine with managed settings, and its hooks
|
|
273
|
+
// module answers every classic.* event with next.to(e, "append"): the event
|
|
274
|
+
// jumps past the user tier, where a plugin you installed (this one) sits, so
|
|
275
|
+
// none of our classic.* hooks run (Claude Code then says "leftoff: its
|
|
276
|
+
// classic.Stop hook was skipped. "cc-plugin-sec-default" runs first and sent
|
|
277
|
+
// the event on past it."). Our other events are untouched: session.start,
|
|
278
|
+
// prompt.submit, turn.start, turn.complete, tool.check and tool.call.
|
|
279
|
+
// So: each of those notes the classic event that should come with it; when it
|
|
280
|
+
// doesn't come within the grace and no classic hook has run in this process at
|
|
281
|
+
// all, the classic event counts as passed over and the daemon hears which.
|
|
282
|
+
function sawClassic($, name) {
|
|
283
|
+
if (name === 'PermissionRequest') st.permAt = Date.now()
|
|
284
|
+
if (st.classicSeen) return
|
|
285
|
+
st.classicSeen = true
|
|
286
|
+
if (st.hooksSkipped && st.hooksSkipped.length) { // a slow SessionStart we counted out too early, a setting changed: they run
|
|
287
|
+
st.hooksSkipped = []
|
|
288
|
+
sendOrSpool($, 'heartbeat', { hooksSkipped: [], fromMod: true }).catch(() => {})
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function expectClassic($, name, graceMs) {
|
|
293
|
+
if (st.classicSeen || st.internal) return
|
|
294
|
+
$.clock.after(graceMs || SKIP_GRACE_MS, () => { noteSkipped($, name).catch(() => {}) })
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function noteSkipped($, name) {
|
|
298
|
+
if (st.classicSeen || st.internal || !st.sessionId) return
|
|
299
|
+
const cur = st.hooksSkipped || []
|
|
300
|
+
if (cur.includes(name)) return
|
|
301
|
+
st.hooksSkipped = cur.concat(name)
|
|
302
|
+
await sendOrSpool($, 'heartbeat', { hooksSkipped: st.hooksSkipped, fromMod: true })
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// A permission prompt, seen from tool.check: next(e) is the whole check
|
|
306
|
+
// (rules, modes, other plugins' hooks, Claude Code's own), and { decision: "ask" }
|
|
307
|
+
// with approver "person" is the terminal's permission dialog (a host such as
|
|
308
|
+
// headless answers "host"; no approver means it is never asked). We only
|
|
309
|
+
// look: the answer goes back as it came. Reported as Claude Code's own
|
|
310
|
+
// permission_prompt notification would be, unless classic.PermissionRequest
|
|
311
|
+
// ran for it (then permissionHook has put it on the board, answerable there).
|
|
312
|
+
function watchAsk($, e) {
|
|
313
|
+
if (st.internal || st.headless || !st.sessionId) return
|
|
314
|
+
const at = Date.now()
|
|
315
|
+
const id = String(e.tool_use_id || (String(e.tool || '') + '@' + at))
|
|
316
|
+
st.watchAsks[id] = { tool: e.tool, at, reported: false, done: false }
|
|
317
|
+
expectClassic($, 'PermissionRequest')
|
|
318
|
+
$.clock.after(PERM_GRACE_MS, () => { reportAsk($, id, e).catch(() => {}) })
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function reportAsk($, id, e) {
|
|
322
|
+
const w = st.watchAsks[id]
|
|
323
|
+
if (!w || w.done) return
|
|
324
|
+
if (st.permAt >= w.at) { delete st.watchAsks[id]; return } // its PermissionRequest hook ran: the board has it
|
|
325
|
+
w.reported = true
|
|
326
|
+
await sendOrSpool($, 'notification', {
|
|
327
|
+
notificationType: 'permission_prompt', message: clip(askMessage(e.tool), 200), tool: e.tool || null, fromMod: true,
|
|
328
|
+
...(st.hooksSkipped && st.hooksSkipped.length ? { hooksSkipped: st.hooksSkipped } : {}),
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function askMessage(tool) {
|
|
333
|
+
if (tool === 'AskUserQuestion') return 'Claude has a question for you'
|
|
334
|
+
return 'Claude needs your permission to use ' + (tool || 'a tool')
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// The prompt went away: the tool runs (tool.call) or the turn is over. Says so
|
|
338
|
+
// only for prompts the mod reported (an activity the board reads as a tool
|
|
339
|
+
// that ran, which ends "Needs you" for a permission prompt).
|
|
340
|
+
async function settleAsks($, toolUseId, tool) {
|
|
341
|
+
let reported = false
|
|
342
|
+
for (const id of Object.keys(st.watchAsks)) {
|
|
343
|
+
const w = st.watchAsks[id]
|
|
344
|
+
if (toolUseId !== undefined && (toolUseId ? id !== String(toolUseId) : w.tool !== tool)) continue
|
|
345
|
+
w.done = true
|
|
346
|
+
if (w.reported) reported = true
|
|
347
|
+
delete st.watchAsks[id]
|
|
348
|
+
}
|
|
349
|
+
if (reported) await sendOrSpool($, 'activity', { kind: 'tool', phase: 'post', tool: tool || null, fromMod: true })
|
|
350
|
+
}
|
|
351
|
+
|
|
246
352
|
// The commands, in the order they're tried. Claude Code 2.1.295's built-ins
|
|
247
353
|
// own /checkpoint (an alias of /rewind) and /name (an alias of /rename), so
|
|
248
354
|
// ours share a /cp prefix; each command lists fallbacks in case a later
|
|
@@ -259,6 +365,8 @@ const COMMANDS = [
|
|
|
259
365
|
{ 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 },
|
|
260
366
|
// v0.8.2: checkpoint, mark the session Parked (not Closed when it exits), then close it.
|
|
261
367
|
{ key: 'park', names: ['cp-park', 'leftoff-park'], description: 'Leftoff: checkpoint and park this session to come back to (your note becomes the Next line), then exit', argumentHint: '[note]', immediate: false },
|
|
368
|
+
// 2026-11-03 (1b): the repo's accepted decisions into the conversation on demand (when SessionStart was skipped, or after /clear).
|
|
369
|
+
{ key: 'decisions', names: ['cp-decisions', 'leftoff-decisions'], description: 'Leftoff: give Claude this repo\'s accepted decisions (the same list a new session starts with)', immediate: false },
|
|
262
370
|
{ 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 },
|
|
263
371
|
]
|
|
264
372
|
|
|
@@ -371,6 +479,37 @@ async function unlockOwnParkLocks($, cwd) {
|
|
|
371
479
|
} catch {}
|
|
372
480
|
}
|
|
373
481
|
|
|
482
|
+
// ------------------------------------------------------------ decisions (2026-11-03)
|
|
483
|
+
|
|
484
|
+
// The daemon's short read of this repo's accepted decisions (id + one line each, capped), or null:
|
|
485
|
+
// nothing for Leftoff's own runs or a board-run headless session, when the daemon doesn't answer quickly,
|
|
486
|
+
// or when the repo has none.
|
|
487
|
+
const DECISIONS_TIMEOUT_MS = 800
|
|
488
|
+
async function decisionContext($, e) {
|
|
489
|
+
if (st.internal || await isHeadless($)) return null
|
|
490
|
+
const q = '?session=' + encodeURIComponent(e.session_id || st.sessionId || '') + (e.cwd ? '&cwd=' + encodeURIComponent(e.cwd) : '')
|
|
491
|
+
const r = await api($, 'GET', '/v1/decisions/context' + q, undefined, DECISIONS_TIMEOUT_MS)
|
|
492
|
+
const t = r && r.ok && r.json && typeof r.json.context === 'string' ? r.json.context.trim() : ''
|
|
493
|
+
return t ? t.slice(0, 2000) : null
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 1b /cp-decisions: the same text SessionStart adds (src/decisions.js decisionContextText, from the daemon),
|
|
497
|
+
// shown to you and handed to Claude as the command's context. The daemon notes that this session was given them.
|
|
498
|
+
// Runs where the classic hooks are skipped: a command is not a classic event (Claude Code's Security default
|
|
499
|
+
// hooks classic.* but not command.run; checked on 2.1.317 and 2.1.318).
|
|
500
|
+
async function decisionsCommand($) {
|
|
501
|
+
if (st.internal) return { text: 'Leftoff: not in Leftoff\'s own runs.' }
|
|
502
|
+
let cwd = null
|
|
503
|
+
try { cwd = await $.session.cwd() } catch {}
|
|
504
|
+
const sid = st.sessionId || ''
|
|
505
|
+
const r = await api($, 'GET', '/v1/decisions/context?via=command&session=' + encodeURIComponent(sid) + (cwd ? '&cwd=' + encodeURIComponent(cwd) : ''), undefined, 2000)
|
|
506
|
+
if (!r || !r.ok || !r.json) return { text: 'Leftoff: the daemon didn\'t answer, so this repo\'s decisions couldn\'t be read (leftoff status).' }
|
|
507
|
+
const t = typeof r.json.context === 'string' ? r.json.context.trim().slice(0, 2000) : ''
|
|
508
|
+
if (!t) return { text: r.json.repo ? 'Leftoff: this repo has no accepted decisions yet. Review proposed ones on the board.' : 'Leftoff: this folder isn\'t a repo Leftoff keeps a decision log for.' }
|
|
509
|
+
const n = Number(r.json.count) || t.split('\n').filter((l) => l.startsWith('- ')).length
|
|
510
|
+
return { text: 'Leftoff: gave Claude this repo\'s ' + n + ' accepted decision' + (n === 1 ? '' : 's') + ':\n' + t, context: [t] }
|
|
511
|
+
}
|
|
512
|
+
|
|
374
513
|
// ------------------------------------------------------------ lists (v0.5.1)
|
|
375
514
|
|
|
376
515
|
// This session's saved lists, when they changed since Claude last heard
|
|
@@ -735,6 +874,7 @@ async function makeCheckpoint($, trigger, userNote, length, opts = {}) {
|
|
|
735
874
|
doing: parsed.doing, stoppedAt: parsed.stoppedAt, next: parsed.next, openQuestion: parsed.openQuestion || null,
|
|
736
875
|
userNote: userNote || null, filesTouched: ctx.filesTouched, turnsSinceLast: record.turnsSinceCheckpoint || 0,
|
|
737
876
|
parseOk: parsed.parseOk, ...(parsed.format === 'markdown' ? { format: 'markdown' } : {}), generator, length: ctx.length,
|
|
877
|
+
decided: mergeDecided(ctx.found, generator && generator.by === 'heuristic' ? [] : parsed.decided, { earlier: st.lastCheckpoint && st.lastCheckpoint.decided }), // 2026-11-03 decision memory
|
|
738
878
|
decisions: parsed.decisions || [], questions: parsed.questions || [], files: parsed.files || [],
|
|
739
879
|
didntWork: parsed.didntWork || [], recap: parsed.recap || '', nextSteps: parsed.nextSteps || [],
|
|
740
880
|
}
|
|
@@ -967,14 +1107,22 @@ function startReplyLoop($) {
|
|
|
967
1107
|
let backoff = 0
|
|
968
1108
|
while (st.replyLoop && st.sessionId) {
|
|
969
1109
|
const sid = st.sessionId
|
|
970
|
-
const r = await api($, 'GET', '/v1/sessions/' + encodeURIComponent(sid) + '/replies/next?ms=' + ASK_POLL_MS, undefined, ASK_POLL_MS + 5000)
|
|
1110
|
+
const r = await api($, 'GET', '/v1/sessions/' + encodeURIComponent(sid) + '/replies/next?cmds=1&ms=' + ASK_POLL_MS, undefined, ASK_POLL_MS + 5000)
|
|
971
1111
|
if (!r || !r.ok) {
|
|
972
1112
|
// Daemon down: try again on the heartbeat rather than spinning.
|
|
973
1113
|
st.replyLoop = false
|
|
974
1114
|
return
|
|
975
1115
|
}
|
|
976
1116
|
const reply = r.json && r.json.reply
|
|
977
|
-
|
|
1117
|
+
// a slash command from the board (the daemon checked it against its list):
|
|
1118
|
+
// Claude Code runs it as if typed, queued behind a running turn (as /rename)
|
|
1119
|
+
if (reply && reply.command && typeof reply.command.name === 'string' && /^[a-z][\w-]{0,30}$/.test(reply.command.name) && sid === st.sessionId) {
|
|
1120
|
+
try {
|
|
1121
|
+
await $.command.run({ command: reply.command.name, args: String(reply.command.args || '').replace(/[\r\n]+/g, ' ').slice(0, 500) })
|
|
1122
|
+
} catch (err) {
|
|
1123
|
+
try { $.ui.toast('Leftoff: couldn’t run the board’s /' + reply.command.name + ': ' + String((err && err.message) || err).slice(0, 120)) } catch {}
|
|
1124
|
+
}
|
|
1125
|
+
} else if (reply && reply.text && !String(reply.text).startsWith('/') && sid === st.sessionId) {
|
|
978
1126
|
try {
|
|
979
1127
|
await $.prompt.submit({ text: String(reply.text) })
|
|
980
1128
|
} catch (err) {
|
|
@@ -1029,6 +1177,7 @@ export function register(on, options) {
|
|
|
1029
1177
|
st.heartbeat = $.clock.every(60000, () => { heartbeat($).catch(() => {}); startReplyLoop($) })
|
|
1030
1178
|
st.ready = true
|
|
1031
1179
|
startReplyLoop($)
|
|
1180
|
+
expectClassic($, 'SessionStart', START_SKIP_GRACE_MS) // 2026-11-03 passed over? (sawClassic)
|
|
1032
1181
|
} catch {}
|
|
1033
1182
|
// Commands last, each on its own: a taken name throws, so a
|
|
1034
1183
|
// clash costs only that command, and registerCommands reports it.
|
|
@@ -1037,6 +1186,7 @@ export function register(on, options) {
|
|
|
1037
1186
|
})
|
|
1038
1187
|
|
|
1039
1188
|
on('classic.SessionStart', async ($, e, next) => {
|
|
1189
|
+
sawClassic($, 'SessionStart')
|
|
1040
1190
|
if (!(await internalOnce($)) && !st.internal && e.session_id) {
|
|
1041
1191
|
let parentSessionId
|
|
1042
1192
|
if (st.sessionId && e.session_id !== st.sessionId) {
|
|
@@ -1064,11 +1214,20 @@ export function register(on, options) {
|
|
|
1064
1214
|
const name = own && !own.id ? own.name : (own && own.name) || st.daemonTitle || null
|
|
1065
1215
|
await applyTabTitle($, name, { force: !!parentSessionId, current: e.session_title || null })
|
|
1066
1216
|
}
|
|
1217
|
+
// 2026-11-03 decision memory: the repo's accepted decisions go to Claude as this event's context.
|
|
1218
|
+
// Added to what the hooks below (yours included) answer, never in place of it.
|
|
1219
|
+
const ctx = await decisionContext($, e)
|
|
1220
|
+
if (ctx) {
|
|
1221
|
+
const res = await next(e)
|
|
1222
|
+
const r = res && typeof res === 'object' ? res : {}
|
|
1223
|
+
return { ...r, additionalContext: [...(Array.isArray(r.additionalContext) ? r.additionalContext : []), ctx] }
|
|
1224
|
+
}
|
|
1067
1225
|
}
|
|
1068
1226
|
return next(e)
|
|
1069
1227
|
})
|
|
1070
1228
|
|
|
1071
1229
|
on('classic.UserPromptSubmit', async ($, e, next) => {
|
|
1230
|
+
sawClassic($, 'UserPromptSubmit')
|
|
1072
1231
|
await sendOrSpool($, 'activity', { kind: 'prompt', prompt: clip(e.prompt || '', 300), sessionTitle: e.session_title && e.session_title === st.tabTitle ? null : e.session_title, ...moved(e) }) // our own /rename isn't a name you chose
|
|
1073
1232
|
return next(e)
|
|
1074
1233
|
})
|
|
@@ -1078,11 +1237,13 @@ export function register(on, options) {
|
|
|
1078
1237
|
// context entry: the list, its numbers, and `leftoff todo done <list> <n>`.
|
|
1079
1238
|
// Context goes on the event before next(); on the result it would be too late.
|
|
1080
1239
|
on('prompt.submit', async ($, e, next) => {
|
|
1240
|
+
expectClassic($, 'UserPromptSubmit')
|
|
1081
1241
|
const ctx = await listContextFor($)
|
|
1082
1242
|
return next(ctx ? { ...e, context: [...(Array.isArray(e.context) ? e.context : []), ctx] } : e)
|
|
1083
1243
|
})
|
|
1084
1244
|
|
|
1085
1245
|
on('turn.start', async ($, e, next) => {
|
|
1246
|
+
st.turnStartAt = Date.now()
|
|
1086
1247
|
await sendOrSpool($, 'turn_started', {})
|
|
1087
1248
|
return next(e)
|
|
1088
1249
|
})
|
|
@@ -1090,11 +1251,21 @@ export function register(on, options) {
|
|
|
1090
1251
|
on('turn.complete', async ($, e, next) => {
|
|
1091
1252
|
// classic.Stop reports a normal end with its details; Stop doesn't fire for
|
|
1092
1253
|
// an interrupted or failed turn, so those are reported here.
|
|
1254
|
+
await settleAsks($, undefined).catch(() => {}) // 2026-11-03 a permission prompt the mod reported is over with the turn
|
|
1093
1255
|
if (e.reason === 'aborted' || e.reason === 'error' || e.reason === 'refusal') await sendOrSpool($, 'turn_completed', { endReason: e.reason })
|
|
1256
|
+
// 2026-11-03 Claude Code doesn't run every Stop for every end of a turn
|
|
1257
|
+
// (2.1.317: a turn its loop ends runs Stop without the in-process hooks,
|
|
1258
|
+
// and a tool can end a turn), and then this turn stayed open on the board
|
|
1259
|
+
// for good. If no Stop came for this turn shortly after it completed, the
|
|
1260
|
+
// end is reported from here; a Stop that comes later is the same turn
|
|
1261
|
+
// (the daemon doesn't count it twice).
|
|
1262
|
+
else { const at = Date.now(); $.clock.after(STOP_GRACE_MS, () => { stopFallback($, at, e.reason).catch(() => {}) }); expectClassic($, 'Stop') }
|
|
1094
1263
|
return next(e)
|
|
1095
1264
|
})
|
|
1096
1265
|
|
|
1097
1266
|
on('classic.Stop', async ($, e, next) => {
|
|
1267
|
+
sawClassic($, 'Stop')
|
|
1268
|
+
st.stopAt = Date.now()
|
|
1098
1269
|
// v0.5.1: a checklist of 3+ in this reply (a cheap regex, no model) -> the
|
|
1099
1270
|
// board's card offers "Claude wrote a 7-step list · Save". Nothing is saved here.
|
|
1100
1271
|
const listHint = listHintFrom(e.last_assistant_message || '', { fallback: st.daemonTitle || null })
|
|
@@ -1118,20 +1289,36 @@ export function register(on, options) {
|
|
|
1118
1289
|
})
|
|
1119
1290
|
|
|
1120
1291
|
on('classic.Notification', async ($, e, next) => {
|
|
1292
|
+
sawClassic($, 'Notification')
|
|
1121
1293
|
await sendOrSpool($, 'notification', { notificationType: e.notification_type || null, message: clip(e.message || '', 200), ...moved(e) })
|
|
1122
1294
|
return next(e)
|
|
1123
1295
|
})
|
|
1124
1296
|
|
|
1125
|
-
on('classic.PermissionRequest', permissionHook)
|
|
1297
|
+
on('classic.PermissionRequest', async ($, e, next) => { sawClassic($, 'PermissionRequest'); return permissionHook($, e, next) })
|
|
1298
|
+
|
|
1299
|
+
// 2026-11-03 a permission prompt, seen whether or not classic.PermissionRequest
|
|
1300
|
+
// reaches us (see watchAsk): we only look, and hand the answer back as it came.
|
|
1301
|
+
on('tool.check', async ($, e, next) => {
|
|
1302
|
+
const r = await next(e)
|
|
1303
|
+
try { if (r && r.decision === 'ask' && e && e.approver === 'person') watchAsk($, e) } catch {}
|
|
1304
|
+
return r
|
|
1305
|
+
})
|
|
1306
|
+
|
|
1307
|
+
// The tool runs: a prompt the mod reported for it was answered in the terminal.
|
|
1308
|
+
on('tool.call', async ($, e, next) => {
|
|
1309
|
+
if (e && Object.keys(st.watchAsks).length) await settleAsks($, e.tool_use_id || null, e.tool).catch(() => {})
|
|
1310
|
+
return next(e)
|
|
1311
|
+
})
|
|
1126
1312
|
|
|
1127
1313
|
on('classic.PostToolUse', async ($, e, next) => {
|
|
1314
|
+
sawClassic($, 'PostToolUse')
|
|
1128
1315
|
await toolRan($, e)
|
|
1129
1316
|
const m = moved(e) // EnterWorktree, or a cd: say so now, not at the end of the turn
|
|
1130
1317
|
if (m.cwd || m.transcriptPath) await sendOrSpool($, 'activity', { kind: 'cwd', ...m })
|
|
1131
1318
|
return next(e)
|
|
1132
1319
|
})
|
|
1133
1320
|
|
|
1134
|
-
on('classic.PostToolUseFailure', async ($, e, next) => { await toolRan($, e); return next(e) })
|
|
1321
|
+
on('classic.PostToolUseFailure', async ($, e, next) => { sawClassic($, 'PostToolUseFailure'); await toolRan($, e); return next(e) })
|
|
1135
1322
|
|
|
1136
1323
|
on('session.end', async ($, e, next) => {
|
|
1137
1324
|
// About 1.5 s shared by every SessionEnd hook: post and go. The daemon makes
|
|
@@ -1160,6 +1347,8 @@ export function register(on, options) {
|
|
|
1160
1347
|
on('command.run', { command: 'leftoff-park' }, async ($, e) => parkCommand($, e.args))
|
|
1161
1348
|
|
|
1162
1349
|
on('command.run', { command: 'checkpoints' }, async ($, e) => paneCommand($, e.args))
|
|
1350
|
+
on('command.run', { command: 'cp-decisions' }, async ($) => decisionsCommand($))
|
|
1351
|
+
on('command.run', { command: 'leftoff-decisions' }, async ($) => decisionsCommand($))
|
|
1163
1352
|
|
|
1164
1353
|
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
|
|
1165
1354
|
if (e.requestId !== PANE_ID) return next(e)
|
|
@@ -11,10 +11,12 @@ How to walk through a change:
|
|
|
11
11
|
|
|
12
12
|
1. Order the steps the way the change makes sense to read: the core idea first, then what follows from it. Not file order.
|
|
13
13
|
2. Each step gets a short title and one or two sentences of why: the request it answers, the bug it fixes, or the trade-off you chose and the alternative you didn't take.
|
|
14
|
-
3. Mark what matters. A step that changes behaviour is Core; a small follow-on (config, docs, a helper, a rename) is Supporting. Tests that only follow along, imports and formatting are mechanical and need no story.
|
|
14
|
+
3. Mark what matters. A step that changes behaviour is Core; a small follow-on (config, docs, a helper, a rename) is Supporting. Tests that only follow along (updated to match the change), imports and formatting are mechanical and need no story.
|
|
15
|
+
- A test that doesn't really test anything is never mechanical and never safe to skip. That is a test that asserts nothing meaningful or asserts a tautology, mocks the very thing under test, snapshots its own output, had an assertion loosened or deleted to make it pass, or was skipped or disabled. Put it in a step and give it a look callout at its line saying why.
|
|
15
16
|
4. Point out what deserves a second look, at the exact line: something risky or subtle (look), a guess the code makes (assumption), or behaviour no test covers (untested). Be honest about the parts you were unsure of.
|
|
16
|
-
5.
|
|
17
|
+
5. Cite the decisions behind a step. When Leftoff lists the repo's accepted decisions (D-7f3a: …), and this session's own proposed ones (marked proposed), a step may name the ones it relates to and how: "decisions": [{"id": "D-7f3a", "rel": "follows"}] or [{"id": "D-7f3a", "rel": "departs"}] (a plain "D-7f3a" means follows), using only ids from those lists. A step that reverses or departs from a decision gets a look callout at its line naming the decision and how it departs.
|
|
18
|
+
6. Never paste code. Point at it.
|
|
17
19
|
|
|
18
|
-
When Leftoff asks (the request lists hunks H1…Hn and a JSON shape): reply with that JSON object only, no prose and no code fences. Every ref is a hunk id from the list ("H3", "H3:new 14-19", "H3:old 7"); never invent ids or line numbers; put every hunk in a step or in mechanical. You may read files with Read, Grep and Glob to check details; change nothing.
|
|
20
|
+
When Leftoff asks (the request lists hunks H1…Hn and a JSON shape): reply with that JSON object only, no prose and no code fences. Every ref is a hunk id from the list ("H3", "H3:new 14-19", "H3:old 7"); never invent ids or line numbers; put every hunk in a step or in mechanical. A step's "decisions" are ids from the decisions listed, never made up, each with "rel" "follows" or "departs". You may read files with Read, Grep and Glob to check details; change nothing.
|
|
19
21
|
|
|
20
22
|
When the developer asks in the terminal: give the same walkthrough as a short numbered list (title, why, file:line for each step, then what to look at closely).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.9.18-beta.
|
|
3
|
+
"version": "0.9.18-beta.11",
|
|
4
4
|
"description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|