@litfamily/litclaude 1.0.24 → 1.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,13 @@
1
1
  // The LIT band's surface module: the `Client` element lit-mod.mjs draws above the prompt. It runs on
2
2
  // the drawing thread with no mods API, keeps the band's timeline in its local state, and animates on
3
- // the surface's own frame clock only while something moves; once the timeline settles it stops its
4
- // timer and the band stays still until the hooks module hands it a new model.
3
+ // the surface's own frame clock: fast while a transition plays, slower while the ambient loop says
4
+ // LitClaude is at work, and not at all once the card is idle and settled, when it stays still until
5
+ // the hooks module hands it a new model.
5
6
 
6
7
  import { composeBand } from "../lib/mod/frame.mjs";
7
8
  import { TIMINGS, motionProfile } from "../lib/mod/motion.mjs";
8
9
  import { renderBand } from "../lib/mod/render.mjs";
9
- import { advanceTimeline, createTimeline, sampleTimeline, timelineBusy } from "../lib/mod/timeline.mjs";
10
+ import { advanceTimeline, ambientActive, createTimeline, sampleTimeline, timelineBusy } from "../lib/mod/timeline.mjs";
10
11
 
11
12
  // The timeline lives in one holder object that the frame timer and every draw share. The timer
12
13
  // never reads `surface.state` back, so a draw that folded in a new model is never overwritten by a
@@ -17,7 +18,7 @@ export default function LitBand(props, surface) {
17
18
  const empty = () => surface.elements.Box({ children: [] });
18
19
  if (!model) return empty();
19
20
 
20
- const holder = surface.state?.live ?? { now: 0, model: null, timeline: null, stop: null };
21
+ const holder = surface.state?.live ?? { now: 0, model: null, timeline: null, stop: null, interval: null };
21
22
  if (holder.timeline === null) {
22
23
  holder.timeline = createTimeline(model, 0, profile);
23
24
  holder.model = model;
@@ -26,19 +27,25 @@ export default function LitBand(props, surface) {
26
27
  holder.model = model;
27
28
  }
28
29
 
29
- if (holder.stop === null && timelineBusy(holder.timeline, holder.now, profile)) {
30
- holder.stop = surface.every(TIMINGS.frame, () => {
31
- holder.now += TIMINGS.frame;
32
- if (!timelineBusy(holder.timeline, holder.now, profile)) {
33
- holder.stop?.();
34
- holder.stop = null;
35
- }
30
+ const interval = () => (timelineBusy(holder.timeline, holder.now, profile) ? TIMINGS.frame
31
+ : ambientActive(holder.timeline, profile) ? TIMINGS.ambientFrame : null);
32
+ const schedule = () => {
33
+ const wanted = interval();
34
+ if (wanted === holder.interval) return;
35
+ holder.stop?.();
36
+ holder.stop = null;
37
+ holder.interval = wanted;
38
+ if (wanted === null) return;
39
+ holder.stop = surface.every(wanted, () => {
40
+ holder.now += wanted;
41
+ schedule();
36
42
  surface.setState({ live: holder, now: holder.now });
37
43
  });
38
- }
44
+ };
45
+ schedule();
39
46
  if (surface.state?.live !== holder) surface.setState({ live: holder, now: holder.now });
40
47
 
41
- if (!model.visible && !timelineBusy(holder.timeline, holder.now, profile)) return empty();
48
+ if (!model.visible && interval() === null) return empty();
42
49
  const frame = composeBand(model, sampleTimeline(holder.timeline, holder.now, profile), { columns: props.columns, glyphs: props.glyphs, accent: props.accent });
43
50
  return renderBand(surface.elements, frame);
44
51
  }
@@ -1,9 +1,12 @@
1
1
  // LitClaude's Claude Code mod (hooks/hooks.json `modules`). It draws the LIT card above the prompt
2
- // for the whole session (the `band` setting can limit it to active work or turn it off), lights a
3
- // chip for each LitFamily skill that fires, adds the discipline and progress to the spinner, raises
4
- // gate notices, and serves /lit-evidence. It is read-only: it reads LitClaude's own state files and
5
- // the Git HEAD through lib/mod/state.mjs, the settings for the status line's accent, and the host's
6
- // live figures, and never writes, spawns, fetches or calls a model.
2
+ // for the whole session (the `band` setting can limit it to active work or turn it off): where the
3
+ // session stands in the LIT workflow, the tool the main loop is running and for how long, a chip
4
+ // for each LitFamily skill that fires. It adds the discipline and progress to the spinner, raises
5
+ // gate notices, serves /lit-evidence and, when the `notify` option asks, raises a notification
6
+ // when LIT work completes or a long LIT turn ends. It is read-only: it reads LitClaude's own state
7
+ // files and the Git HEAD through lib/mod/state.mjs, the settings for the status line's accent, and
8
+ // the host's live figures, and never writes, spawns, fetches or calls a model. Of a tool call it
9
+ // keeps the tool's name and start time, never its arguments.
7
10
  // Claude Code releases before mods ignore the `modules` key and keep running the settings hooks.
8
11
  //
9
12
  // The mods loader only follows `$` into functions declared at the top of this file, so each helper
@@ -13,6 +16,7 @@ import { composeBand, composeEvidence } from "../lib/mod/frame.mjs";
13
16
  import { TIMINGS } from "../lib/mod/motion.mjs";
14
17
  import { renderBand, renderRows } from "../lib/mod/render.mjs";
15
18
  import { accentFromSettings, normalizeModSettings, resolveGlyphs } from "../lib/mod/settings.mjs";
19
+ import { composeSvgCard } from "../lib/mod/svg-card.mjs";
16
20
  import { chipStates, litSkillId, noteSkill } from "../lib/mod/skills.mjs";
17
21
  import { detectIconFont, readGitBranch, readLedgerTail, readLitSnapshot } from "../lib/mod/state.mjs";
18
22
  import { settledSample } from "../lib/mod/timeline.mjs";
@@ -22,6 +26,7 @@ import {
22
26
  buildEvidenceModel,
23
27
  detectCompletion,
24
28
  diffNotices,
29
+ formatDuration,
25
30
  mergeSpawned,
26
31
  noteAgentActivity,
27
32
  noteSpawn,
@@ -39,7 +44,9 @@ const TABS = Object.freeze([
39
44
  { id: "lanes", label: "lanes", hotkey: "2" },
40
45
  { id: "gates", label: "gates", hotkey: "3" },
41
46
  ]);
42
- const NO_SNAPSHOT = Object.freeze({ ignition: null, work: null, completedWorkIds: [], goal: null, autoloop: null, litPlan: null, probe: null, motion: null, handoff: null });
47
+ const NO_SNAPSHOT = Object.freeze({ ignition: null, work: null, completedWorkIds: [], goal: null, autoloop: null, litPlan: null, probe: null, motion: null, handoff: null, workflow: null });
48
+ // A LIT turn at least this long notifies when it ends, under `notify`.
49
+ const LONG_TURN_MS = 3 * 60 * 1000;
43
50
 
44
51
  const createLive = (options) => ({
45
52
  band: normalizeModSettings(options).band,
@@ -69,11 +76,16 @@ const createLive = (options) => ({
69
76
  settledAt: 0,
70
77
  ignitionAt: undefined,
71
78
  lastDiscipline: null,
79
+ step: null,
80
+ mainToolCalls: 0,
81
+ turnStartedAt: null,
82
+ turnNotified: false,
72
83
  });
73
84
 
74
85
  const stateIo = ($) => ({
75
86
  stat: (path) => $.fs.stat(path, { resolve: true }),
76
87
  read: (path) => $.fs.read(path),
88
+ list: (path) => $.fs.list(path),
77
89
  });
78
90
 
79
91
  const toastText = (raised) => `🔥 ${raised.title}${raised.detail ? ` · ${raised.detail}` : ""}`;
@@ -94,6 +106,7 @@ async function rebuild($, live) {
94
106
  skills: chipStates(live.fired, live.settledAt),
95
107
  lastDiscipline: live.lastDiscipline,
96
108
  git: live.git,
109
+ step: live.step,
97
110
  });
98
111
  live.model = next;
99
112
  if (previous?.visible && !next.visible && !live.exiting) {
@@ -118,10 +131,18 @@ function showNotice($, live, raised) {
118
131
  });
119
132
  }
120
133
 
121
- function celebrate($, live, finish) {
134
+ // The opt-in finish notification. No sound file ships yet, so `finish+sound` notifies like `finish`.
135
+ function notifyFinish($, options, text) {
136
+ if (normalizeModSettings(options).notify === "off") return;
137
+ $.ui.notify(text, { title: "LitClaude" }).catch(() => undefined);
138
+ }
139
+
140
+ function celebrate($, live, finish, options) {
122
141
  if (live.celebrated.has(finish.id)) return;
123
142
  live.celebrated.add(finish.id);
124
143
  live.completion = finish;
144
+ live.turnNotified = true;
145
+ notifyFinish($, options, `LIT complete · ${finish.detail}`);
125
146
  $.clock.after(TIMINGS.completion + 200, () => {
126
147
  if (live.completion?.id !== finish.id) return;
127
148
  live.completion = null;
@@ -148,7 +169,7 @@ function noteIgnition(live, ignition, now) {
148
169
  if (id) live.fired = noteSkill(live.fired, id, ignition.at ?? now);
149
170
  }
150
171
 
151
- async function refresh($, live) {
172
+ async function refresh($, live, options) {
152
173
  if (live.refreshing) return;
153
174
  live.refreshing = true;
154
175
  try {
@@ -166,7 +187,7 @@ async function refresh($, live) {
166
187
  live.snapshot = fresh;
167
188
  noteIgnition(live, fresh.ignition, now);
168
189
  const finish = detectCompletion(before, fresh);
169
- if (finish) celebrate($, live, finish);
190
+ if (finish) celebrate($, live, finish, options);
170
191
  const next = await rebuild($, live);
171
192
  const raised = diffNotices(before, fresh, previousModel, next);
172
193
  if (raised.length > 0) {
@@ -182,12 +203,12 @@ async function refresh($, live) {
182
203
  }
183
204
  }
184
205
 
185
- function refreshSoon($, live) {
206
+ function refreshSoon($, live, options) {
186
207
  if (live.quickPending) return;
187
208
  live.quickPending = true;
188
209
  $.clock.after(QUICK_REFRESH_MS, () => {
189
210
  live.quickPending = false;
190
- void refresh($, live);
211
+ void refresh($, live, options);
191
212
  });
192
213
  }
193
214
 
@@ -209,16 +230,19 @@ export const register = (on, options) => {
209
230
  live.settledAt = await $.clock.now();
210
231
  live.accent = accentFromSettings(await $.settings.read().catch(() => null), await $.env.get("LITCLAUDE_HUD_ACCENT"));
211
232
  await $.command.register({ name: PANE, description: "Show LitClaude goal criteria, evidence, lanes and gates in a pane" });
212
- await refresh($, live);
233
+ await refresh($, live, options);
213
234
  $.clock.every(POLL_MS, () => {
214
- void refresh($, live);
235
+ void refresh($, live, options);
215
236
  });
216
237
  return next(e);
217
238
  });
218
239
 
219
240
  on("turn.start", async ($, e, next) => {
220
241
  live.isWorking = true;
221
- refreshSoon($, live);
242
+ live.mainToolCalls = 0;
243
+ live.turnStartedAt = await $.clock.now();
244
+ live.turnNotified = false;
245
+ refreshSoon($, live, options);
222
246
  return next(e);
223
247
  });
224
248
 
@@ -226,50 +250,81 @@ export const register = (on, options) => {
226
250
  // A subagent's or teammate's turn ends its run, not the main turn the card follows.
227
251
  if (typeof e.agentId === "string") {
228
252
  live.spawned = noteSpawnEnded(live.spawned, e.agentId);
229
- refreshSoon($, live);
253
+ refreshSoon($, live, options);
230
254
  return next(e);
231
255
  }
232
256
  live.isWorking = false;
257
+ live.step = null;
258
+ live.mainToolCalls = 0;
259
+ const now = await $.clock.now();
260
+ const took = Number.isFinite(e.durationMs) ? e.durationMs : now - (live.turnStartedAt ?? now);
261
+ const lit = live.model && (live.model.discipline || live.model.workflow || live.model.skills.some(({ state }) => state === "current"));
262
+ if (lit && !live.turnNotified && took >= LONG_TURN_MS) {
263
+ live.turnNotified = true;
264
+ notifyFinish($, options, `LIT turn finished · ${live.model.title ?? "LitClaude"} · ${formatDuration(took)}`);
265
+ }
233
266
  // The request is over: its chips dim to the recent list until a skill fires again.
234
- live.settledAt = await $.clock.now();
235
- refreshSoon($, live);
267
+ live.settledAt = now;
268
+ refreshSoon($, live, options);
236
269
  return next(e);
237
270
  });
238
271
 
239
272
  on("session.measure", async ($, e, next) => {
240
273
  live.usage = { context: e.context, rateLimits: e.rateLimits };
241
- if (e.changed.includes("context")) refreshSoon($, live);
274
+ if (e.changed.includes("context")) refreshSoon($, live, options);
242
275
  return next(e);
243
276
  });
244
277
 
245
278
  on("tool.call", async ($, e, next) => {
246
- if (e.tool === "Skill") await noteFiring($, live, e.skill);
279
+ // The card follows the main loop: a skill a subagent loads (a reviewer reading `rules`) neither
280
+ // lights a chip nor retitles the card.
281
+ if (e.tool === "Skill" && typeof e.agentId !== "string") await noteFiring($, live, e.skill);
247
282
  if (typeof e.agentId === "string") live.spawned = noteSpawnActivity(live.spawned, e.agentId, await $.clock.now());
248
- const result = await next(e);
283
+ // The live step: the main loop's tool by name and since when, nothing else of the call.
284
+ const step = typeof e.agentId === "string" ? null : { name: e.tool, at: await $.clock.now() };
285
+ if (step) live.mainToolCalls += 1;
286
+ if (step) {
287
+ live.step = step;
288
+ await rebuild($, live);
289
+ }
290
+ let result;
291
+ try {
292
+ result = await next(e);
293
+ } finally {
294
+ if (step && live.step === step) live.step = live.isWorking ? { name: "thinking", at: await $.clock.now() } : null;
295
+ }
249
296
  if (typeof e.agentId === "string") {
250
297
  const now = await $.clock.now();
251
298
  live.activity = noteAgentActivity(live.activity, e.agentId, now);
252
299
  live.spawned = noteSpawnActivity(live.spawned, e.agentId, now);
253
300
  }
254
- refreshSoon($, live);
301
+ refreshSoon($, live, options);
255
302
  return result;
256
303
  }).catch(($, e, next) => next(e));
257
304
 
305
+ // A model request on the main loop: the step reads "thinking" until a tool call names itself.
306
+ on("turn.step", async function* ($, e, next) {
307
+ if (typeof e.agentId !== "string" && live.isWorking && live.step?.name !== "thinking") live.step = { name: "thinking", at: await $.clock.now() };
308
+ return yield* next(e);
309
+ });
310
+
258
311
  // Every subagent, teammate and workflow agent starts here. A workflow's never shows in
259
312
  // `$.agent.list()`, so the card keeps its own record until the agent's turn.complete.
260
313
  on("agent.spawn", async ($, e, next) => {
261
314
  const result = await next(e);
262
315
  if (typeof result?.agentId === "string") {
263
316
  live.spawned = noteSpawn(live.spawned, { id: result.agentId, description: e.description, type: e.subagentType, at: await $.clock.now() });
264
- refreshSoon($, live);
317
+ refreshSoon($, live, options);
265
318
  }
266
319
  return result;
267
320
  }).catch(($, e, next) => next(e));
268
321
 
269
322
  // A typed `/litclaude:<name>` and the Skill tool both expand the skill's prompt here; the text
270
- // passes through untouched.
323
+ // passes through untouched. So does a subagent's preloaded skill (an agent's `skills:` list),
324
+ // and the event does not say whose it is: a firing counts only before the main loop's first tool
325
+ // call of the turn (a typed command) or inside its Skill call.
271
326
  on("skill.prompt", async ($, e, next) => {
272
- await noteFiring($, live, e.skill);
327
+ if (live.mainToolCalls === 0 || live.step?.name === "Skill") await noteFiring($, live, e.skill);
273
328
  return next(e);
274
329
  }).catch(($, e, next) => next(e));
275
330
 
@@ -286,7 +341,14 @@ export const register = (on, options) => {
286
341
  const elements = $.ui.resolve(e);
287
342
  const columns = Math.max(20, e.props.bodyColumns ?? e.viewport?.columns ?? 80);
288
343
  let drawn = null;
289
- if (elements.Client && !live.clientFailed) {
344
+ if (e.surface !== "terminal" && elements.Svg) {
345
+ // A desktop or editor draws Text in a proportional font, so the cell-built card comes apart
346
+ // there; those surfaces get the card as one drawing instead.
347
+ if (model.visible) {
348
+ const { source, alt, animated } = composeSvgCard(model, { motion: settings.motion, accent: live.accent });
349
+ drawn = elements.Svg({ source, alt, ...(animated ? { isInteractive: true } : {}) });
350
+ }
351
+ } else if (elements.Client && !live.clientFailed) {
290
352
  drawn = elements.Client({ key: "lit-band", module: "./lit-band-client.mjs", props: { model, columns, glyphs, motion: settings.motion, accent: live.accent } });
291
353
  } else if (model.visible) {
292
354
  drawn = renderBand(elements, composeBand(model, settledSample(model), { columns, glyphs, accent: live.accent }));