@lawaty/lacode 1.3.0-beta.3 → 1.3.0-beta.5

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 CHANGED
@@ -7,6 +7,48 @@ All notable changes to LaCode are recorded here. The format follows
7
7
  Versions come from git release commits; the npm version is bumped in the same
8
8
  commit as the changelog entry it describes.
9
9
 
10
+ ## [1.3.0-beta.5] — the timeline sidebar renders and updates
11
+
12
+ ### Fixed
13
+
14
+ - **The timeline panel was never shown in the TUI.** Its slot registration sat
15
+ at the end of the voice plugin's setup, after the recorder, daemon and keymap
16
+ work, so an early return or a failure anywhere in that path skipped it. It is
17
+ now registered first and independently of voice, including when voice is
18
+ disabled with `enabled: false`. A registration failure is logged instead of
19
+ being swallowed.
20
+ - **The panel could not update once rendered.** Its rows were driven by
21
+ `solid-js` signals, but the TUI gives the plugin a different Solid instance
22
+ from the renderer's, so signal updates never reached the screen: the panel
23
+ stayed on "No timeline yet" even while the JSONL file grew. The rows are now
24
+ static `text` renderables whose `content` is written on a one-second timer
25
+ through the renderable itself, which is independent of any Solid instance.
26
+
27
+ ## [1.3.0-beta.4] — session events count as life, and the timeline builds on idle
28
+
29
+ ### Fixed
30
+
31
+ - **The hang reaper now treats opencode session and task events as proof of
32
+ life.** A pooled claim was only kept off the silence clock by message traffic.
33
+ `session.status` (`busy`/`retry`), `session.updated`, `todo.updated`,
34
+ `session.compacted`, `permission.asked` and `question.asked` now refresh the
35
+ same activity clock, so a session opencode is actively working on is not
36
+ condemned as hung. The two existing bounds are unchanged: a genuinely silent
37
+ task is still reaped after the idle window, and `MAX_CLAIM_AGE_MS` still kills
38
+ a task that never goes quiet.
39
+ - **The timeline is built when a session goes idle, not only after the
40
+ five-minute debounce.** Pending work is flushed on `session.idle`, so a short
41
+ or one-shot run gets a timeline before the process can exit. The timer remains
42
+ the debounce for an active session.
43
+ - **The timeline keeps the session title.** Only the first tick fetched session
44
+ info; every later summarizer prompt said "Session: untitled". The title is now
45
+ carried on the per-session state.
46
+ - **A failed timeline summarization is logged with a level opencode accepts.**
47
+ The call passed the message as the log *level*, so the runtime rejected the
48
+ payload (`Expected "debug" | "info" | "error" | "warn"`) and the failure was
49
+ invisible. Failures now log at `warn` with a message, and the no-bullets line
50
+ at `debug`.
51
+
10
52
  ## [1.3.0-beta.3] — the 1.3.0-beta.2 notes
11
53
 
12
54
  ### Changed
package/dist/index.js CHANGED
@@ -3017,9 +3017,13 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
3017
3017
  event: async ({ event }) => {
3018
3018
  try {
3019
3019
  const props = event.properties ?? {};
3020
+ const noteLive = (sessionID) => {
3021
+ if (typeof sessionID === "string" && sessionID) noteActivity(s, sessionID);
3022
+ };
3020
3023
  if (event.type === "session.status") {
3021
3024
  const sessionID = typeof props.sessionID === "string" ? props.sessionID : void 0;
3022
3025
  const status2 = props.status;
3026
+ if (status2?.type === "busy" || status2?.type === "retry") noteLive(sessionID);
3023
3027
  const action = status2?.action;
3024
3028
  const provider = typeof action?.provider === "string" ? action.provider : void 0;
3025
3029
  const reason2 = action?.reason ?? "";
@@ -3193,7 +3197,10 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
3193
3197
  }
3194
3198
  if (event.type === "session.created") {
3195
3199
  const info2 = props.info;
3196
- if (info2?.id && info2.parentID) bindChild(s, info2.parentID, info2.id);
3200
+ if (info2?.id && info2.parentID) {
3201
+ bindChild(s, info2.parentID, info2.id);
3202
+ noteLive(info2.id);
3203
+ }
3197
3204
  return;
3198
3205
  }
3199
3206
  if (event.type === "message.updated" || event.type === "message.part.updated") {
@@ -3235,6 +3242,18 @@ var agentPoolHooks = (async ({ client, serverUrl, directory }, options) => {
3235
3242
  }
3236
3243
  return;
3237
3244
  }
3245
+ if (event.type === "session.updated") {
3246
+ noteLive(props.info?.id);
3247
+ return;
3248
+ }
3249
+ if (event.type === "todo.updated" || event.type === "session.compacted") {
3250
+ noteLive(props.sessionID);
3251
+ return;
3252
+ }
3253
+ if (event.type === "permission.asked" || event.type === "question.asked") {
3254
+ noteLive(props.sessionID);
3255
+ return;
3256
+ }
3238
3257
  if (event.type !== "message.updated") return;
3239
3258
  const info = props.info;
3240
3259
  if (!info || info.role !== "assistant" || !info.error) return;
@@ -6770,6 +6789,18 @@ function timelineHooks(input, options) {
6770
6789
  if (state2.timer || state2.running) return;
6771
6790
  schedule(id, state2);
6772
6791
  };
6792
+ function flush(id) {
6793
+ if (disabled || disposed) return;
6794
+ const state2 = sessions.get(id);
6795
+ if (!state2) return;
6796
+ if (state2.root === false) return;
6797
+ if (!state2.pending || state2.running) return;
6798
+ if (state2.timer) {
6799
+ clearTimeout(state2.timer);
6800
+ state2.timer = void 0;
6801
+ }
6802
+ void tick(id, state2);
6803
+ }
6773
6804
  function schedule(id, state2) {
6774
6805
  if (state2.timer) clearTimeout(state2.timer);
6775
6806
  state2.timer = setTimeout(() => {
@@ -6866,7 +6897,7 @@ function timelineHooks(input, options) {
6866
6897
  return { ok: true, bullets: [] };
6867
6898
  } else {
6868
6899
  if (process.env.LACODE_TIMELINE_DEBUG === "1") {
6869
- log("no bullet lines", { sessionID: id, text: text.slice(0, 300) });
6900
+ log("debug", "timeline: no bullet lines", { sessionID: id, text: text.slice(0, 300) });
6870
6901
  }
6871
6902
  return { ok: false, bullets: [], error: "no bullet lines" };
6872
6903
  }
@@ -6894,6 +6925,7 @@ function timelineHooks(input, options) {
6894
6925
  return;
6895
6926
  }
6896
6927
  title = info.title;
6928
+ state2.title = info.title ?? state2.title;
6897
6929
  state2.root = !info.parentID && !isInternalTitle(info.title);
6898
6930
  if (!state2.root) {
6899
6931
  sessions.delete(id);
@@ -6906,14 +6938,14 @@ function timelineHooks(input, options) {
6906
6938
  if (!excerpt.text) {
6907
6939
  return;
6908
6940
  }
6909
- const result = await summarize(id, info.title ?? "untitled", excerpt.text, readEntries(id, 4));
6941
+ const result = await summarize(id, state2.title ?? info?.title ?? "untitled", excerpt.text, readEntries(id, 4));
6910
6942
  if (result.ok) {
6911
6943
  if (result.bullets.length) appendEntries(id, result.bullets, Date.now(), excerpt.next);
6912
6944
  state2.cursor = excerpt.next;
6913
6945
  state2.failures = 0;
6914
6946
  } else {
6915
6947
  state2.failures++;
6916
- log("summarize failed", { sessionID: id, error: result.error });
6948
+ log("warn", "timeline: summarize failed", { sessionID: id, error: result.error });
6917
6949
  }
6918
6950
  } catch (error) {
6919
6951
  debug("tick failed", { sessionID: id, error });
@@ -7021,6 +7053,11 @@ function timelineHooks(input, options) {
7021
7053
  event: async ({ event }) => {
7022
7054
  try {
7023
7055
  switch (event.type) {
7056
+ case "session.idle": {
7057
+ const id = event.properties.sessionID;
7058
+ if (id) flush(id);
7059
+ return;
7060
+ }
7024
7061
  case "message.updated": {
7025
7062
  const id = event.properties.info?.sessionID;
7026
7063
  if (!id) return;
package/dist/tui.js CHANGED
@@ -822,56 +822,72 @@ function createVoiceClient(options, deps) {
822
822
  }
823
823
 
824
824
  // src/tui/timeline.tsx
825
- import { createSignal, createEffect, onCleanup, For } from "solid-js";
826
825
  import { readFileSync as readFileSync3 } from "node:fs";
827
826
  import { homedir as homedir2 } from "node:os";
828
827
  import { join as join3, resolve } from "node:path";
829
828
  import { jsx, jsxs } from "@opentui/solid/jsx-runtime";
830
829
  var registered = false;
830
+ var ENTRY_LINES = 14;
831
+ var readEntries = (id) => {
832
+ if (typeof id !== "string" || !/^[A-Za-z0-9_.-]+$/.test(id)) return [];
833
+ const rawDir = process.env.LACODE_TIMELINE_DIR;
834
+ const dir = rawDir ? resolve(rawDir) : join3(homedir2(), ".local/share/opencode/lacode/timeline");
835
+ try {
836
+ const raw = readFileSync3(join3(dir, id + ".jsonl"), "utf8");
837
+ const parsed = [];
838
+ for (const line of raw.split("\n")) {
839
+ const trimmed = line.trim();
840
+ if (!trimmed) continue;
841
+ try {
842
+ const obj = JSON.parse(trimmed);
843
+ if (obj && typeof obj.ts === "number" && typeof obj.text === "string") parsed.push(obj.text);
844
+ } catch {
845
+ }
846
+ }
847
+ return parsed.slice(-ENTRY_LINES).reverse();
848
+ } catch {
849
+ return [];
850
+ }
851
+ };
831
852
  var tui = async (api) => {
832
853
  if (registered) return;
833
854
  registered = true;
834
855
  api.slots.register({
835
856
  order: 150,
836
857
  slots: {
858
+ // The rows are static and their text is written through the renderable's
859
+ // `content` setter. Solid signals are deliberately NOT used here: the
860
+ // plugin's `solid-js` is a different instance from the TUI renderer's, so
861
+ // signal updates never reach the screen. Plain renderables always do.
837
862
  sidebar_content(ctx, props) {
838
- const [entries, setEntries] = createSignal([]);
839
- const read = (id) => {
840
- if (typeof id !== "string" || !/^[A-Za-z0-9_.-]+$/.test(id)) {
841
- setEntries([]);
842
- return;
843
- }
844
- const rawDir = process.env.LACODE_TIMELINE_DIR;
845
- const dir = rawDir ? resolve(rawDir) : join3(homedir2(), ".local/share/opencode/lacode/timeline");
846
- const file = join3(dir, id + ".jsonl");
847
- try {
848
- const raw = readFileSync3(file, "utf8");
849
- const parsed = [];
850
- for (const line of raw.split("\n")) {
851
- const trimmed = line.trim();
852
- if (!trimmed) continue;
853
- try {
854
- const obj = JSON.parse(trimmed);
855
- if (obj && typeof obj.ts === "number" && typeof obj.text === "string") {
856
- parsed.push(obj.text);
857
- }
858
- } catch {
859
- }
860
- }
861
- setEntries(parsed.slice(-14).reverse());
862
- } catch {
863
- setEntries([]);
863
+ const rows = [];
864
+ const muted = ctx.theme.current.textMuted;
865
+ const paint = () => {
866
+ const entries = readEntries(props.session_id);
867
+ for (let i = 0; i < ENTRY_LINES; i++) {
868
+ const row = rows[i];
869
+ if (!row) continue;
870
+ const text = entries[i];
871
+ row.content = text ? `\u2022 ${text}` : i === 0 && entries.length === 0 ? "No timeline yet" : "";
872
+ row.requestRender?.();
864
873
  }
865
874
  };
866
- createEffect(() => {
867
- const id = props.session_id;
868
- read(id);
869
- const timer = setInterval(() => read(id), 1e3);
870
- onCleanup(() => clearInterval(timer));
871
- });
875
+ const timer = setInterval(paint, 1e3);
876
+ timer.unref?.();
877
+ api.lifecycle.onDispose(() => clearInterval(timer));
872
878
  return /* @__PURE__ */ jsxs("box", { flexDirection: "column", children: [
873
- /* @__PURE__ */ jsx("text", { fg: ctx.theme.current.textMuted, children: "Timeline" }),
874
- entries().length === 0 ? /* @__PURE__ */ jsx("text", { fg: ctx.theme.current.textMuted, children: "No timeline yet" }) : /* @__PURE__ */ jsx(For, { each: entries(), children: (text) => /* @__PURE__ */ jsx("text", { children: `\u2022 ${text}` }) })
879
+ /* @__PURE__ */ jsx("text", { fg: muted, children: "Timeline" }),
880
+ Array.from({ length: ENTRY_LINES }, (_, index) => /* @__PURE__ */ jsx(
881
+ "text",
882
+ {
883
+ fg: index === 0 ? muted : void 0,
884
+ ref: (node) => {
885
+ rows[index] = node;
886
+ if (index === ENTRY_LINES - 1) paint();
887
+ },
888
+ children: ""
889
+ }
890
+ ))
875
891
  ] });
876
892
  }
877
893
  }
@@ -1112,6 +1128,16 @@ async function answerWithText(api, opts, text, request) {
1112
1128
  toast(api, { variant: "success", message: labels });
1113
1129
  }
1114
1130
  var tui2 = async (api, options) => {
1131
+ const timelineOpts = options ?? {};
1132
+ const timelineOff = timelineOpts.timeline === false || typeof timelineOpts.timeline === "object" && timelineOpts.timeline !== null && timelineOpts.timeline.enabled === false;
1133
+ if (!timelineOff) {
1134
+ try {
1135
+ await timeline_default.tui(api, options);
1136
+ } catch (error) {
1137
+ void api.client?.app?.log?.({ body: { service: "timeline", level: "warn", message: `timeline sidebar registration failed: ${String(error)}` } })?.catch?.(() => {
1138
+ });
1139
+ }
1140
+ }
1115
1141
  const opts = resolveOptions(options);
1116
1142
  if (!opts.enabled) return;
1117
1143
  daemonConnected = false;
@@ -1333,14 +1359,6 @@ var tui2 = async (api, options) => {
1333
1359
  bindings.push({ key: opts.answerKey, cmd: "lacode.voice.answer", desc: "Voice answer" });
1334
1360
  }
1335
1361
  api.keymap.registerLayer({ mode: "base", commands, bindings });
1336
- const timelineOpts = options ?? {};
1337
- const timelineOff = timelineOpts.timeline === false || typeof timelineOpts.timeline === "object" && timelineOpts.timeline !== null && timelineOpts.timeline.enabled === false;
1338
- if (!timelineOff) {
1339
- try {
1340
- await timeline_default.tui(api, options);
1341
- } catch {
1342
- }
1343
- }
1344
1362
  };
1345
1363
  var mod = {
1346
1364
  id: "lacode.voice",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lawaty/lacode",
3
- "version": "1.3.0-beta.3",
3
+ "version": "1.3.0-beta.5",
4
4
  "description": "Ultimate opencode setup for Go users — cost-controlled pooled subagents, hang recovery, and notifications for opencode.",
5
5
  "license": "MIT",
6
6
  "type": "module",