@litfamily/litclaude 1.0.23 → 1.0.25
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 +16 -0
- package/README.md +8 -8
- package/README_ko-KR.md +8 -8
- package/bin/litclaude-ai.js +90 -1
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/hooks.md +27 -10
- package/package.json +1 -1
- package/plugins/litclaude/.claude-plugin/plugin.json +2 -2
- package/plugins/litclaude/bin/litclaude-mcp.js +1 -1
- package/plugins/litclaude/hooks/lit-band-client.mjs +20 -13
- package/plugins/litclaude/hooks/lit-mod.mjs +30 -2
- package/plugins/litclaude/lib/canonical-skill-resources.mjs +4 -4
- package/plugins/litclaude/lib/mod/frame.mjs +61 -22
- package/plugins/litclaude/lib/mod/motion.mjs +11 -5
- package/plugins/litclaude/lib/mod/settings.mjs +4 -4
- package/plugins/litclaude/lib/mod/timeline.mjs +28 -0
- package/plugins/litclaude/lib/mod/view.mjs +37 -6
- package/plugins/litclaude/lib/office-runtime-lock/package-lock.json +167 -119
- package/plugins/litclaude/lib/office-runtime-lock/package.json +1 -1
- package/plugins/litclaude/skills/readme-studio/templates/hyperframes-cover/package-lock.json +169 -121
- package/plugins/litclaude/skills/readme-studio/templates/remotion-cover/package-lock.json +3 -3
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// ╭─ [🔥 LIT] · lit-loop ─────────────────────────── WORK ● ─╮
|
|
8
8
|
// │ ● PLAN ━━ [WORK] ━━ ○ REVIEW goal ██░░░ 2/5 │
|
|
9
9
|
// │ skills [⚡ lit-plan] [⚡ litresearch] [↯ lit-docx] │
|
|
10
|
-
// ╰─ ctx 42% ▰▰▱▱▱ · 5h 31% · git main
|
|
10
|
+
// ╰─ ctx 42% ▰▰▱▱▱ · 5h 31% · 1w 12% · git main ─────────────╯
|
|
11
11
|
//
|
|
12
12
|
// Each bracket pair stands for the two end cells of a filled pill: the brand on pink, the active
|
|
13
13
|
// phase and this request's skills on the accent with dark text, skills from earlier requests on
|
|
@@ -44,7 +44,7 @@ export const PALETTE = Object.freeze({
|
|
|
44
44
|
// The status line's level colours (xterm 71, 178, 167) and its empty bar cells (238).
|
|
45
45
|
const HUD = Object.freeze({ green: "#5faf5f", yellow: "#d7af00", red: "#d75f5f", empty: "#444444" });
|
|
46
46
|
// Past this many columns the card stops growing, so a wide terminal does not stretch it thin.
|
|
47
|
-
export const CARD_MAX =
|
|
47
|
+
export const CARD_MAX = 120;
|
|
48
48
|
// Pill colours: dark text on the bright fills, the muted pill for earlier skills and +N, the brand
|
|
49
49
|
// pink of the status line's prefix, and the connectors between phases.
|
|
50
50
|
const PILL = Object.freeze({ ink: "#0b0f14", muted: "#2d333b", mutedText: "#adb5bf", brand: PALETTE.pink, rail: "#46505c" });
|
|
@@ -145,25 +145,59 @@ const fitJoined = (segments, room) => {
|
|
|
145
145
|
|
|
146
146
|
const joinSegments = (kept, joinerColor) => kept.flatMap((part, index) => (index > 0 ? [span(part.joiner, { fg: joinerColor }), ...part.spans] : part.spans));
|
|
147
147
|
|
|
148
|
+
const COMET_TAIL = 12;
|
|
149
|
+
|
|
150
|
+
// The comet's colour `k` of the way from its tail (0) to its head (1): flame orange fading in, pink
|
|
151
|
+
// through the body and white at the head, laid over the rule's own colour.
|
|
152
|
+
const cometColor = (base, k) => {
|
|
153
|
+
const hue = k > 0.8 ? lerpColor(PALETTE.pink, "#ffffff", (k - 0.8) / 0.2) : lerpColor(PALETTE.orange, PALETTE.pink, k / 0.8);
|
|
154
|
+
return lerpColor(base, hue, Math.min(1, k * 1.6));
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// The rule of an edge, `count` cells from column `start`. With a comet ({ x, dir }: its head's
|
|
158
|
+
// column and travel direction) the cells behind the head light up, brightest at the head, and the
|
|
159
|
+
// rule becomes one span per cell; without one it stays a single span.
|
|
160
|
+
const ruleSpans = (count, color, start, comet) => {
|
|
161
|
+
if (!comet) return [span("─".repeat(count), { fg: color })];
|
|
162
|
+
return Array.from({ length: count }, (_, i) => {
|
|
163
|
+
const behind = comet.dir * (comet.x - (start + i));
|
|
164
|
+
const k = behind >= 0 && behind <= COMET_TAIL ? 1 - behind / COMET_TAIL : 0;
|
|
165
|
+
return span("─", { fg: k > 0 ? cometColor(color, k) : color });
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
|
|
148
169
|
// An edge of the card: `open` and `close` are its corners, `left` its title or figures and
|
|
149
170
|
// `right` the phase indicator, with the rule filling whatever room is left.
|
|
150
|
-
const edgeRow = (open, left, right, close, width, color) => {
|
|
171
|
+
const edgeRow = (open, left, right, close, width, color, comet = null) => {
|
|
151
172
|
const tail = right.length ? [span(" "), ...right, span(` ${close}`, { fg: color })] : [span(close, { fg: color })];
|
|
152
173
|
const fill = Math.max(1, width - textWidth(open) - rowWidth(left) - 1 - rowWidth(tail));
|
|
153
|
-
|
|
174
|
+
const start = textWidth(open) + rowWidth(left) + 1;
|
|
175
|
+
return clipRow([span(open, { fg: color }), ...left, span(" "), ...ruleSpans(fill, color, start, comet), ...tail], width);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Where the ambient comet is on this card: along the top rule left to right for the first half of
|
|
179
|
+
// its loop, then back along the bottom rule, starting and ending just off the card.
|
|
180
|
+
const cometOn = (sample, edge, width) => {
|
|
181
|
+
const u = sample.ambient?.comet;
|
|
182
|
+
if (u === null || u === undefined) return null;
|
|
183
|
+
const span = width + 2 * COMET_TAIL;
|
|
184
|
+
if (edge === "top") return u < 0.5 ? { x: u * 2 * span - COMET_TAIL, dir: 1 } : null;
|
|
185
|
+
return u >= 0.5 ? { x: width + COMET_TAIL - (u - 0.5) * 2 * span, dir: -1 } : null;
|
|
154
186
|
};
|
|
155
187
|
|
|
188
|
+
// While LitClaude works the brand pill breathes from its pink toward the flame orange and back.
|
|
156
189
|
const brandSpans = (sample, glyphs) => {
|
|
157
190
|
const reveal = revealText("🔥 LIT", sample.pills[0]);
|
|
158
191
|
if (reveal === null) return [];
|
|
159
|
-
|
|
192
|
+
const fill = sample.ambient ? lerpColor(PILL.brand, PALETTE.orange, 0.85 * sample.ambient.breath) : PILL.brand;
|
|
193
|
+
return pill(litText(reveal, { fg: PILL.ink, bg: fill, bold: true }, sample.completion?.shimmer ?? null), fill, glyphs);
|
|
160
194
|
};
|
|
161
195
|
const indicatorSpans = (model, sample, glyphs, accent) => {
|
|
162
196
|
if (sample.completion) return [span(`DONE ${glyphs.done}`, { fg: PALETTE.green, bold: true })];
|
|
163
197
|
if (model.idle) return [span("idle", { fg: PALETTE.dim })];
|
|
164
198
|
const current = PHASES.find((id) => model.phases[id] === "now");
|
|
165
199
|
if (current) return [span(`${LABELS[current]} ${warned(current, sample) ? glyphs.warn : glyphs.now}`, { fg: phaseColor(current, sample, inkOf(accent)), bold: true })];
|
|
166
|
-
if (PHASES.every((id) => model.phases[id] === "done")) return [span(`DONE ${glyphs.done}`, { fg: PALETTE.green, bold: true })];
|
|
200
|
+
if (PHASES.every((id) => model.phases[id] === "done") && model.lanes.count === 0) return [span(`DONE ${glyphs.done}`, { fg: PALETTE.green, bold: true })];
|
|
167
201
|
return [span(`LIVE ${glyphs.now}`, { fg: accent, bold: true })];
|
|
168
202
|
};
|
|
169
203
|
|
|
@@ -177,10 +211,11 @@ const topEdge = (model, sample, { width, glyphs, accent, edge }) => {
|
|
|
177
211
|
const title = (text) => (text ? [span(" · ", { fg: PALETTE.dim }), span(text, nameStyle)] : []);
|
|
178
212
|
const fixed = textWidth("╭─ ") + rowWidth(brand) + 1 + 1;
|
|
179
213
|
const withRight = fixed + rowWidth(title(name)) + 1 + rowWidth(right) + textWidth(" ─╮");
|
|
180
|
-
|
|
214
|
+
const comet = cometOn(sample, "top", width);
|
|
215
|
+
if (withRight <= width) return edgeRow("╭─ ", [...brand, ...title(name)], right, "─╮", width, edge, comet);
|
|
181
216
|
const room = width - fixed - textWidth("─╮") - textWidth(" · ");
|
|
182
217
|
const shown = room >= 4 ? truncateToWidth(name, room) : "";
|
|
183
|
-
return edgeRow("╭─ ", [...brand, ...title(shown)], [], "─╮", width, edge);
|
|
218
|
+
return edgeRow("╭─ ", [...brand, ...title(shown)], [], "─╮", width, edge, comet);
|
|
184
219
|
};
|
|
185
220
|
|
|
186
221
|
const barSpans = (fraction, sample, cells) => barCells(fraction, cells, sample.fillGlow ?? 0)
|
|
@@ -223,7 +258,8 @@ const phaseTrack = (model, sample, { glyphs, accent, rail, short }) => {
|
|
|
223
258
|
if (out.length) out.push(span(` ${glyphs.rail.repeat(rail)} `, { fg: sample.phases[PHASES[index - 1]].to === "done" ? ink.done : PILL.rail }));
|
|
224
259
|
if (state === "now") {
|
|
225
260
|
const fill = model.idle ? lerpColor(phaseColor(id, sample, ink), PILL.muted, 0.5) : phaseColor(id, sample, ink);
|
|
226
|
-
|
|
261
|
+
const sweep = model.idle ? null : sample.ambient?.sweep ?? null;
|
|
262
|
+
out.push(...pill(litText(shown.slice(0, label.length).padEnd(label.length), { fg: PILL.ink, bg: fill, bold: true }, sweep), fill, glyphs));
|
|
227
263
|
} else {
|
|
228
264
|
out.push(span(warned(id, sample) ? shown.replace(/^[●○]/u, glyphs.warn) : shown, { fg: phaseColor(id, sample, ink) }));
|
|
229
265
|
}
|
|
@@ -264,7 +300,7 @@ const CHIP_TONE = Object.freeze({
|
|
|
264
300
|
// One chip pill: the bolt and the skill name, on the accent for this request or muted for an
|
|
265
301
|
// earlier one. A chip that just arrived carries full motion's light along its name, or fills up
|
|
266
302
|
// from the muted pill under subtle; a chip changing state crossfades its colours.
|
|
267
|
-
const chipSpans = (chip, timed, glyphs, accent) => {
|
|
303
|
+
const chipSpans = (chip, timed, glyphs, accent, sweep = null) => {
|
|
268
304
|
const to = timed?.to ?? chip.state;
|
|
269
305
|
const from = timed?.from ?? to;
|
|
270
306
|
const mix = timed?.mix ?? 1;
|
|
@@ -275,7 +311,7 @@ const chipSpans = (chip, timed, glyphs, accent) => {
|
|
|
275
311
|
const bold = to === "current";
|
|
276
312
|
return pill([
|
|
277
313
|
span(`${to === "current" ? glyphs.bolt : glyphs.recentBolt} `, { fg: text, bg: fill }),
|
|
278
|
-
...litText(chip.id, { fg: text, bg: fill, bold }, timed?.shimmer),
|
|
314
|
+
...litText(chip.id, { fg: text, bg: fill, bold }, timed?.shimmer ?? (to === "current" && arrive >= 1 ? sweep : null)),
|
|
279
315
|
], fill, glyphs);
|
|
280
316
|
};
|
|
281
317
|
|
|
@@ -296,7 +332,8 @@ const chipsRow = (model, sample, { inner, glyphs, accent }) => {
|
|
|
296
332
|
const row = inner >= 30 ? [span("skills", { fg: PALETTE.dim })] : [];
|
|
297
333
|
let shown = 0;
|
|
298
334
|
for (const chip of chips) {
|
|
299
|
-
|
|
335
|
+
// While LitClaude works the light that crosses the active phase crosses this request's chips too.
|
|
336
|
+
const entry = chipSpans(chip, sample.chips?.[chip.id], glyphs, accent, model.idle ? null : sample.ambient?.sweep ?? null);
|
|
300
337
|
const gap = row.length ? [span(" ")] : [];
|
|
301
338
|
const rest = chips.length - shown - 1;
|
|
302
339
|
const reserve = rest > 0 ? 1 + rowWidth(mutedPill(`+${rest}`, glyphs)) : 0;
|
|
@@ -346,10 +383,10 @@ const completionRow = (sample, inner) => {
|
|
|
346
383
|
|
|
347
384
|
const levelColor = (percent) => (percent >= 90 ? HUD.red : percent >= 70 ? HUD.yellow : HUD.green);
|
|
348
385
|
|
|
349
|
-
// ╰─ ctx 42% ▰▰▱▱▱ · 5h 31% · git main ───╯, or a warning first when one is up. The branch goes
|
|
350
|
-
// first on a narrow card, then the
|
|
351
|
-
const bottomEdge = (model, { width, glyphs, accent, edge }) => {
|
|
352
|
-
const { context,
|
|
386
|
+
// ╰─ ctx 42% ▰▰▱▱▱ · 5h 31% · 1w 12% · git main ───╯, or a warning first when one is up. The branch goes
|
|
387
|
+
// first on a narrow card, then the weekly window, the five-hour one and the context bar.
|
|
388
|
+
const bottomEdge = (model, sample, { width, glyphs, accent, edge }) => {
|
|
389
|
+
const { context, rates, git } = model.figures;
|
|
353
390
|
const warning = model.warnings[0];
|
|
354
391
|
const segments = [];
|
|
355
392
|
if (warning) {
|
|
@@ -361,19 +398,21 @@ const bottomEdge = (model, { width, glyphs, accent, edge }) => {
|
|
|
361
398
|
const filled = Math.max(0, Math.min(5, Math.round(context / 20)));
|
|
362
399
|
segments.push(segment("ctxbar", 2, [span("▰".repeat(filled), { fg: accent }), span("▱".repeat(5 - filled), { fg: HUD.empty })], " "));
|
|
363
400
|
}
|
|
364
|
-
|
|
401
|
+
rates.forEach((rate, index) => segments.push(segment(`rate-${rate.label}`, 3 + index * 0.5, [span(`${rate.label} `, { fg: PALETTE.dim }), span(`${rate.percent}%`, { fg: levelColor(rate.percent) })], " · ")));
|
|
365
402
|
if (git) segments.push(segment("git", 4, [span(`${glyphs.branch} `, { fg: PALETTE.dim }), span(git, { fg: accent })], " · "));
|
|
366
403
|
const room = width - textWidth("╰─ ") - 1 - 1 - textWidth("─╯");
|
|
367
404
|
let kept = fitJoined(segments, room);
|
|
368
405
|
if (kept[0]?.id === "ctxbar") kept = kept.slice(1);
|
|
369
406
|
if (kept.length === 0 && warning) kept = [{ ...segments[0], spans: clipRow(segments[0].spans, room) }];
|
|
370
|
-
return edgeRow("╰─ ", joinSegments(kept, edge), [], "─╯", width, edge);
|
|
407
|
+
return edgeRow("╰─ ", joinSegments(kept, edge), [], "─╯", width, edge, cometOn(sample, "bottom", width));
|
|
371
408
|
};
|
|
372
409
|
|
|
373
|
-
|
|
410
|
+
// The border: red or amber under a warning, otherwise the accent, dimmer while idle and breathing
|
|
411
|
+
// toward full accent with the brand pill while LitClaude works.
|
|
412
|
+
const edgeColor = (model, sample, accent) => {
|
|
374
413
|
if (model.warnings.some(({ tone }) => tone === "red")) return PALETTE.red;
|
|
375
414
|
if (model.warnings.length) return PALETTE.amber;
|
|
376
|
-
return lerpColor(PALETTE.faint, accent, model.idle ? 0.35 : 0.6);
|
|
415
|
+
return lerpColor(PALETTE.faint, accent, model.idle ? 0.35 : 0.6 + 0.4 * (sample.ambient?.breath ?? 0));
|
|
377
416
|
};
|
|
378
417
|
|
|
379
418
|
/**
|
|
@@ -385,7 +424,7 @@ export const composeBand = (model, sample, { columns, glyphs: glyphMode = "basic
|
|
|
385
424
|
const glyphs = glyphSet(glyphMode);
|
|
386
425
|
const width = Math.max(12, Math.min(CARD_MAX, columns));
|
|
387
426
|
const inner = width - 4;
|
|
388
|
-
const edge = edgeColor(model, accent);
|
|
427
|
+
const edge = edgeColor(model, sample, accent);
|
|
389
428
|
const side = (row) => [span("│ ", { fg: edge }), ...row, span(" │", { fg: edge })];
|
|
390
429
|
const options = { width, inner, glyphs, accent, edge };
|
|
391
430
|
const middle = sample.completion ? completionRow(sample, inner) : sample.notice ? noticeRow(sample, inner) : chipsRow(model, sample, options);
|
|
@@ -393,7 +432,7 @@ export const composeBand = (model, sample, { columns, glyphs: glyphMode = "basic
|
|
|
393
432
|
mode: "card",
|
|
394
433
|
border: edge,
|
|
395
434
|
width,
|
|
396
|
-
rows: [topEdge(model, sample, options), side(phaseRow(model, sample, options)), side(middle), bottomEdge(model, options)],
|
|
435
|
+
rows: [topEdge(model, sample, options), side(phaseRow(model, sample, options)), side(middle), bottomEdge(model, sample, options)],
|
|
397
436
|
};
|
|
398
437
|
};
|
|
399
438
|
|
|
@@ -27,18 +27,24 @@ export const TIMINGS = Object.freeze({
|
|
|
27
27
|
completion: 1200,
|
|
28
28
|
exit: 150,
|
|
29
29
|
frame: 33,
|
|
30
|
+
breath: 2400,
|
|
31
|
+
sweep: 1800,
|
|
32
|
+
comet: 2600,
|
|
33
|
+
ambientFrame: 66,
|
|
30
34
|
});
|
|
31
35
|
|
|
32
|
-
// full: the approved motion
|
|
33
|
-
//
|
|
36
|
+
// full: the approved motion, and while LitClaude works the brand breathes, a light sweeps the active
|
|
37
|
+
// phase and a comet runs around the card's rules. subtle: no overshoot, no shimmer or embers, three
|
|
38
|
+
// quarters the time, and only the breathing while working. off: every duration is zero, so the band
|
|
39
|
+
// draws its final frame and never starts a frame timer.
|
|
34
40
|
export const motionProfile = (mode) => {
|
|
35
41
|
if (mode === "off") {
|
|
36
|
-
return { mode, duration: () => 0, enterEase: EASE.linear, noticeEase: EASE.linear, shimmer: false, embers: false };
|
|
42
|
+
return { mode, duration: () => 0, enterEase: EASE.linear, noticeEase: EASE.linear, shimmer: false, embers: false, ambient: null };
|
|
37
43
|
}
|
|
38
44
|
if (mode === "subtle") {
|
|
39
|
-
return { mode, duration: (ms) => Math.round(ms * 0.75), enterEase: EASE.outCubic, noticeEase: EASE.outCubic, shimmer: false, embers: false };
|
|
45
|
+
return { mode, duration: (ms) => Math.round(ms * 0.75), enterEase: EASE.outCubic, noticeEase: EASE.outCubic, shimmer: false, embers: false, ambient: { sweep: false, comet: false } };
|
|
40
46
|
}
|
|
41
|
-
return { mode: "full", duration: (ms) => ms, enterEase: EASE.outBack, noticeEase: EASE.outBack, shimmer: true, embers: true };
|
|
47
|
+
return { mode: "full", duration: (ms) => ms, enterEase: EASE.outBack, noticeEase: EASE.outBack, shimmer: true, embers: true, ambient: { sweep: true, comet: true } };
|
|
42
48
|
};
|
|
43
49
|
|
|
44
50
|
export const clamp01 = (x) => (x <= 0 ? 0 : x >= 1 ? 1 : x);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// one; the values are checked here instead, and anything unknown falls back to the default (the
|
|
4
4
|
// first choice).
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
export const MOD_CHOICES = Object.freeze({
|
|
7
7
|
motion: ["full", "subtle", "off"],
|
|
8
8
|
glyphs: ["auto", "nerd", "basic"],
|
|
9
9
|
band: ["always", "auto", "off"],
|
|
@@ -15,9 +15,9 @@ const pick = (value, choices) => {
|
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
export const normalizeModSettings = (options) => ({
|
|
18
|
-
motion: pick(options?.motion,
|
|
19
|
-
glyphs: pick(options?.glyphs,
|
|
20
|
-
band: pick(options?.band,
|
|
18
|
+
motion: pick(options?.motion, MOD_CHOICES.motion),
|
|
19
|
+
glyphs: pick(options?.glyphs, MOD_CHOICES.glyphs),
|
|
20
|
+
band: pick(options?.band, MOD_CHOICES.band),
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
// The glyph set one surface draws. Private-use Nerd Font glyphs only reach the terminal, which
|
|
@@ -22,9 +22,17 @@ const chipsOf = (model, now, previous = null) => Object.fromEntries((model.skill
|
|
|
22
22
|
return [chip.id, known];
|
|
23
23
|
}));
|
|
24
24
|
|
|
25
|
+
// The ambient loop runs while the card shows LitClaude at work, so motion itself says "active";
|
|
26
|
+
// `at` is when the current run of work began, so the loop does not restart on every new model.
|
|
27
|
+
const ambientOf = (model, now, profile, previous = null) => {
|
|
28
|
+
const on = Boolean(profile.ambient) && model.visible && !model.idle;
|
|
29
|
+
return { on, at: on && previous?.on ? previous.at : now };
|
|
30
|
+
};
|
|
31
|
+
|
|
25
32
|
export const createTimeline = (model, now, profile) => {
|
|
26
33
|
const restAt = now + entryEnd(profile);
|
|
27
34
|
return {
|
|
35
|
+
ambient: ambientOf(model, now, profile),
|
|
28
36
|
enterAt: now,
|
|
29
37
|
exitAt: null,
|
|
30
38
|
fill: { from: 0, to: fractionOf(model), at: restAt, dur: profile.duration(TIMINGS.fill) },
|
|
@@ -63,9 +71,28 @@ export const advanceTimeline = (timeline, model, now, profile) => {
|
|
|
63
71
|
next.completion = model.completion ? { data: model.completion, at: now } : null;
|
|
64
72
|
}
|
|
65
73
|
next.chips = chipsOf(model, now, timeline.chips);
|
|
74
|
+
next.ambient = ambientOf(model, now, profile, timeline.ambient);
|
|
66
75
|
return next;
|
|
67
76
|
};
|
|
68
77
|
|
|
78
|
+
/** True while the ambient loop runs; the surface module then keeps a slower frame timer going. */
|
|
79
|
+
export const ambientActive = (timeline, profile) => Boolean(profile.ambient) && timeline.exitAt === null && timeline.ambient.on;
|
|
80
|
+
|
|
81
|
+
// One frame of the ambient loop: `breath` (0 to 1 and back over TIMINGS.breath), where the light
|
|
82
|
+
// stands on the active phase (`sweep`, null between passes) and how far the comet has gone around
|
|
83
|
+
// the card (`comet`, 0 to 1: the top rule left to right, then the bottom rule right to left).
|
|
84
|
+
const ambientSample = (timeline, now, profile) => {
|
|
85
|
+
if (!ambientActive(timeline, profile)) return null;
|
|
86
|
+
const elapsed = Math.max(0, now - timeline.ambient.at);
|
|
87
|
+
const breath = 0.5 - 0.5 * Math.cos((2 * Math.PI * elapsed) / TIMINGS.breath);
|
|
88
|
+
const pass = ((elapsed % TIMINGS.sweep) / TIMINGS.sweep) * 1.6 - 0.3;
|
|
89
|
+
return {
|
|
90
|
+
breath,
|
|
91
|
+
sweep: profile.ambient.sweep && pass >= -0.2 && pass <= 1.2 ? pass : null,
|
|
92
|
+
comet: profile.ambient.comet ? (elapsed % TIMINGS.comet) / TIMINGS.comet : null,
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
|
|
69
96
|
export const timelineBusy = (timeline, now, profile) => {
|
|
70
97
|
const d = profile.duration;
|
|
71
98
|
if (timeline.exitAt !== null) return now < timeline.exitAt + d(TIMINGS.exit);
|
|
@@ -142,6 +169,7 @@ export const sampleTimeline = (timeline, now, profile) => {
|
|
|
142
169
|
notice,
|
|
143
170
|
completion,
|
|
144
171
|
chips,
|
|
172
|
+
ambient: ambientSample(timeline, now, profile),
|
|
145
173
|
};
|
|
146
174
|
};
|
|
147
175
|
|
|
@@ -5,10 +5,14 @@
|
|
|
5
5
|
import { AUTOLOOP_LIMITS, GATE_LIMITS } from "./state.mjs";
|
|
6
6
|
|
|
7
7
|
export const STALL_MS = 10 * 60 * 1000;
|
|
8
|
+
// An agent quiet for this long no longer counts as running: a crashed loop or a lost report must not
|
|
9
|
+
// hold the card at work for the rest of the session.
|
|
10
|
+
export const AGENT_STALE_MS = 30 * 60 * 1000;
|
|
8
11
|
// The context warning starts this many points before the auto-handoff percent.
|
|
9
12
|
export const CONTEXT_LEAD = 5;
|
|
10
13
|
|
|
11
14
|
const LIVE_AGENT = new Set(["running", "pending", "waiting", "idle"]);
|
|
15
|
+
const ENDED_AGENT = new Set(["completed", "failed", "killed"]);
|
|
12
16
|
const NONTERMINAL_GOAL = new Set(["active", "blocked", "review_blocked", "needs_user_decision"]);
|
|
13
17
|
const WORK_DISCIPLINES = new Set(["lit-loop", "litwork", "litgoal", "start-work", "native-workflow"]);
|
|
14
18
|
|
|
@@ -44,6 +48,31 @@ export const trackAgents = (activity, agents, previousStatus, now) => {
|
|
|
44
48
|
export const noteAgentActivity = (activity, agentId, now) =>
|
|
45
49
|
activity.first[agentId] === undefined ? activity : { ...activity, last: { ...activity.last, [agentId]: now } };
|
|
46
50
|
|
|
51
|
+
// `spawned` maps an agent id to what `agent.spawn` said of it and when it was last seen working. It
|
|
52
|
+
// covers the agents `$.agent.list()` never shows (a workflow script's) and the moment before the
|
|
53
|
+
// list catches up; the agent's own `turn.complete` ends it.
|
|
54
|
+
export const noteSpawn = (spawned, { id, description, type, at }) => ({ ...spawned, [id]: { id, description, type, last: at } });
|
|
55
|
+
|
|
56
|
+
export const noteSpawnActivity = (spawned, id, at) => (spawned[id] ? { ...spawned, [id]: { ...spawned[id], last: at } } : spawned);
|
|
57
|
+
|
|
58
|
+
export const noteSpawnEnded = (spawned, id) => Object.fromEntries(Object.entries(spawned).filter(([key]) => key !== id));
|
|
59
|
+
|
|
60
|
+
// Folds the spawn records into one `$.agent.list()` reading. The list speaks for the agents it shows:
|
|
61
|
+
// a record whose agent it lists as ended is dropped. A record it does not show runs until its
|
|
62
|
+
// `turn.complete`, or until it has been quiet for AGENT_STALE_MS.
|
|
63
|
+
export const mergeSpawned = (listed, spawned, now) => {
|
|
64
|
+
const byId = new Map(listed.map((agent) => [agent.id, agent]));
|
|
65
|
+
const kept = {};
|
|
66
|
+
const unlisted = [];
|
|
67
|
+
for (const record of Object.values(spawned)) {
|
|
68
|
+
const row = byId.get(record.id);
|
|
69
|
+
if (row ? ENDED_AGENT.has(row.status) : now - record.last > AGENT_STALE_MS) continue;
|
|
70
|
+
kept[record.id] = record;
|
|
71
|
+
if (!row) unlisted.push({ id: record.id, description: record.description, type: record.type, status: "running" });
|
|
72
|
+
}
|
|
73
|
+
return { agents: [...listed, ...unlisted], spawned: kept };
|
|
74
|
+
};
|
|
75
|
+
|
|
47
76
|
export const stalledLanes = (agents, activity, now, thresholdMs = STALL_MS) =>
|
|
48
77
|
agents
|
|
49
78
|
.filter((agent) => LIVE_AGENT.has(agent.status) && Number.isFinite(activity.last[agent.id]) && now - activity.last[agent.id] > thresholdMs)
|
|
@@ -108,14 +137,16 @@ const goalSummaryOf = (goal) => {
|
|
|
108
137
|
return { status: goal.status, passed: goal.criteria.filter(({ status }) => status === "pass").length, total: goal.criteria.length };
|
|
109
138
|
};
|
|
110
139
|
|
|
111
|
-
// The bottom edge's live figures, as the status line shows them: context fill, the five-hour
|
|
112
|
-
//
|
|
140
|
+
// The bottom edge's live figures, as the status line shows them: context fill, the five-hour and
|
|
141
|
+
// weekly windows and the Git branch.
|
|
113
142
|
const figuresOf = (usage, git) => {
|
|
114
143
|
const context = Number.isFinite(usage?.context?.percent) ? Math.round(usage.context.percent) : null;
|
|
115
144
|
const windows = Array.isArray(usage?.rateLimits) ? usage.rateLimits : [];
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
145
|
+
const rates = [["five_hour", "5h"], ["seven_day", "1w"]].flatMap(([kind, label]) => {
|
|
146
|
+
const window = windows.find((entry) => entry.kind === kind);
|
|
147
|
+
return window && Number.isFinite(window.percentUsed) ? [{ label, percent: Math.round(window.percentUsed) }] : [];
|
|
148
|
+
});
|
|
149
|
+
return { context, rates, git: typeof git === "string" && git ? git : null };
|
|
119
150
|
};
|
|
120
151
|
|
|
121
152
|
/**
|
|
@@ -129,7 +160,7 @@ export const buildBandModel = ({ snapshot, agents = [], activity = EMPTY_ACTIVIT
|
|
|
129
160
|
const progress = progressOf(snapshot);
|
|
130
161
|
const completing = completion !== null;
|
|
131
162
|
const phases = phasesOf({ snapshot, discipline, progress, completing });
|
|
132
|
-
const liveAgents = agents.filter((agent) => LIVE_AGENT.has(agent.status));
|
|
163
|
+
const liveAgents = agents.filter((agent) => LIVE_AGENT.has(agent.status) && !(now - activity.last[agent.id] > AGENT_STALE_MS));
|
|
133
164
|
const boulderLanes = (snapshot.work?.lanes ?? []).filter(({ status }) => status === "active");
|
|
134
165
|
const stalls = stalledLanes(agents, activity, now);
|
|
135
166
|
const stalledIds = new Set(stalls.map(({ id }) => id));
|