loom-agent 1.3.2 → 1.5.0

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/src/tui/App.tsx CHANGED
@@ -63,6 +63,7 @@ import { loadConfig, saveConfig, getBaseUrl } from "../config/settings.js";
63
63
  import { loadAgents, resolveAgent } from "../core/agents.js";
64
64
  import { snapshotBefore, snapshotAfter, snapshotBashBefore, diffBashAfter, clearFileDiffs } from "../core/file-diffs.js";
65
65
  import { createRestorePoint, listRestorePoints, restoreTo } from "../core/restore.js";
66
+ import { TranscriptUndo } from "../core/transcript-undo.js";
66
67
  import { PROVIDERS, PROVIDER_ORDER, PROVIDER_LABELS } from "../providers/index.js";
67
68
  import * as plugin from "../core/plugin-cmd.js";
68
69
  import * as sandbox from "../core/sandbox.js";
@@ -71,6 +72,13 @@ import { listSkills } from "../skills/skills-manager.js";
71
72
 
72
73
  let _vimReg = ""; // vim NORMAL-mode paste register
73
74
 
75
+ // Transcript undo/redo — one undoable step per user turn. The engine is pure
76
+ // (src/core/transcript-undo.js); App owns the stacks and clones snapshots.
77
+ const _undo = new TranscriptUndo(50);
78
+ // Restore-point id created for the most recent turn, attached to the snapshot
79
+ // pushed just before that turn so an undo can also roll the working tree back.
80
+ let _lastRestoreId: string | null = null;
81
+
74
82
  const ui = palette("loom");
75
83
 
76
84
  export function App(props: { initialPrompt?: string; resumeSession?: string; autoMode?: boolean }) {
@@ -168,18 +176,37 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
168
176
  // Docker-install resume: while an install is pending, "docker is ready"
169
177
  // continues the sandbox setup in THIS session instead of going to the
170
178
  // model. Outside the pending flow the phrase is ordinary conversation.
179
+ // All docker state checks are ASYNC (spawn-based) — a sync status call
180
+ // here froze the whole TUI whenever the docker daemon was slow.
171
181
  if (sandbox.isReadySignal(raw)) {
172
- var st0 = sandbox.sandboxStatus();
173
- if (st0.pending || st0.docker === "ready" && st0.container === "absent" && /set ?up|build|create|make/i.test(raw)) {
182
+ if (sandbox.sandboxPending()) {
174
183
  recordPrompt(raw); setDraft("");
175
184
  appendMessage({ role: "user", content: raw });
176
- if (st0.docker === "ready" || st0.docker === "no_daemon") {
177
- appendMessage({ role: "system", content: "Docker detected \u2014 pick an OS to finish the sandbox setup." });
178
- openModal({ type: "sandbox" });
179
- } else {
180
- sandbox.markInstallPending();
181
- appendMessage({ role: "system", content: sandbox.installGuide() });
182
- }
185
+ sandbox.sandboxStatusAsync().then(function(st0) {
186
+ if (st0.docker === "ready" || st0.docker === "no_daemon") {
187
+ appendMessage({ role: "system", content: "Docker detected \u2014 pick an OS to finish the sandbox setup." });
188
+ openModal({ type: "sandbox" });
189
+ } else {
190
+ sandbox.markInstallPending();
191
+ appendMessage({ role: "system", content: sandbox.installGuide() });
192
+ }
193
+ });
194
+ return;
195
+ }
196
+ if (!thinking()) {
197
+ // Might be a "set up the sandbox" request (docker ready, no container
198
+ // yet). Check async, then either open the wizard or treat as chat.
199
+ sandbox.sandboxStatusAsync().then(function(st0) {
200
+ if (st0.docker === "ready" && st0.container === "absent" && /set ?up|build|create|make/i.test(raw)) {
201
+ recordPrompt(raw); setDraft("");
202
+ appendMessage({ role: "user", content: raw });
203
+ appendMessage({ role: "system", content: "Docker detected \u2014 pick an OS to finish the sandbox setup." });
204
+ openModal({ type: "sandbox" });
205
+ } else {
206
+ recordPrompt(raw); setDraft("");
207
+ runPrompt(raw, false);
208
+ }
209
+ });
183
210
  return;
184
211
  }
185
212
  }
@@ -210,10 +237,28 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
210
237
  }
211
238
 
212
239
  function runPrompt(raw: string, shown: boolean, agentId?: string | null, userText?: string) {
240
+ // Undo checkpoint: capture the transcript as it is BEFORE this turn so
241
+ // /undo can step back to it. Done synchronously before any message is
242
+ // appended; the restore point is created in the background below and its
243
+ // id is stitched onto the snapshot so undo can also roll files back.
244
+ var preUi = messages().slice();
245
+ var preSess = getSession().messages.slice();
246
+ var preTodos = (getSession().todos || []).slice();
247
+ var preStep = _undo.push({ ui: preUi, session: preSess, todos: preTodos }, raw);
248
+
213
249
  if (!shown) appendMessage({ role: "user", content: raw });
214
250
  // Snapshot the project in the background so /restore always works, without
215
- // stalling the first API call.
216
- setTimeout(function() { try { createRestorePoint(raw); } catch {} }, 0);
251
+ // stalling the first API call. The created point's id is bound to the undo
252
+ // step just pushed, so an undo can rewind both transcript and files.
253
+ setTimeout(function() {
254
+ try {
255
+ var rp = createRestorePoint(raw);
256
+ if (rp && rp.id) {
257
+ _lastRestoreId = rp.id;
258
+ if (preStep) preStep.restoreId = rp.id;
259
+ }
260
+ } catch {}
261
+ }, 0);
217
262
  setThinking(true); setThinkStart(Date.now());
218
263
  var idx = messages().length;
219
264
  appendMessage({ role: "assistant", content: "", thinking: true, agentLabel: agentId || undefined });
@@ -517,6 +562,9 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
517
562
  clearInterval(speedTimer);
518
563
  if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
519
564
  if (offTodos) { offTodos(); offTodos = null; }
565
+ // Per-turn autosave: persist after every turn so a crash/exit never
566
+ // loses more than the last exchange (previously only /exit saved).
567
+ try { saveSession(syncSessionForSave()); } catch {}
520
568
  // Freeze the subagent panel at its final state ("finished").
521
569
  if (subAcc.agent) patchMessageAt(idx, { subagent: { agent: subAcc.agent, text: subAcc.text, log: subAcc.log, status: subAcc.status, done: true } });
522
570
  // Let the settled assistant message render before the global turn state
@@ -682,24 +730,48 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
682
730
  case "context": {
683
731
  const msgs = getSession().messages || [];
684
732
  const est = (s: any) => Math.ceil(String(s == null ? "" : (typeof s === "object" ? JSON.stringify(s) : s)).length / 4);
685
- let sys = 0, user = 0, asst = 0, tool = 0;
733
+ let sys = 0, user = 0, asst = 0, tool = 0, schemas = 0;
686
734
  try { sys += est(getSession().systemPrompt); } catch {}
735
+ try { schemas += est(getSession()._agentBlock) + est(getSession()._resolvedPrimary) + est(getSession()._resolvedSandbox) + Math.ceil((getSession()._toolSchemaChars || 0) / 4); } catch {}
687
736
  for (const m of msgs) {
688
737
  const t = est(m.content) + est(m.toolCalls);
689
738
  if (m.role === "user") user += t; else if (m.role === "assistant") asst += t; else if (m.role === "tool") tool += t; else sys += t;
690
739
  }
691
- const total = sys + user + asst + tool;
740
+ const total = sys + user + asst + tool + schemas;
741
+ // Live window estimate from the session (messages + system + tool
742
+ // schemas) — the percent is recomputed now, not a startup snapshot.
743
+ let winLine = "";
744
+ try {
745
+ const ce = getSession().contextEstimate();
746
+ winLine = "Window: ~" + ce.tokens.toLocaleString() + " / " + ce.window.toLocaleString() + " tokens (" + ce.pct + "% used)";
747
+ } catch {}
692
748
  appendMessage({ role: "system", content: [
693
749
  "=== Context (~tokens, chars/4 estimate) ===",
694
750
  "System+memory: " + sys,
695
751
  "User messages: " + user,
696
752
  "Assistant: " + asst,
697
753
  "Tool results: " + tool,
754
+ "Schemas+turn: " + schemas,
698
755
  "TOTAL: " + total + " (" + msgs.length + " messages)",
756
+ winLine,
699
757
  "", "Tip: /compact frees context when this grows large.",
700
758
  ].join("\n") });
701
759
  return;
702
760
  }
761
+ case "compact": {
762
+ appendMessage({ role: "system", content: "Compacting…" });
763
+ getSession().compact({
764
+ onCompact: (r: any) => {
765
+ appendMessage({ role: "system", content: r.compacted
766
+ ? "Compacted (" + r.method + "): summarized/truncated " + r.removed + " earlier messages; ~" + (r.tokensAfter ?? "?") + " tokens remain."
767
+ : "Nothing to compact — the conversation is still short." });
768
+ },
769
+ }).then((r: any) => {
770
+ if (!r.compacted && !r.reason) appendMessage({ role: "system", content: "Compaction failed — try again." });
771
+ try { saveSession(syncSessionForSave()); } catch {}
772
+ }).catch((e: any) => appendMessage({ role: "system", content: "Compaction failed: " + String(e?.message || e) }));
773
+ return;
774
+ }
703
775
  case "think": {
704
776
  const lvl = String(args[0] || "").toLowerCase();
705
777
  if (!["off", "low", "medium", "high"].includes(lvl)) { showToast("Usage: /think off|low|medium|high", "error"); return; }
@@ -761,6 +833,50 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
761
833
  } catch (e: any) { showToast("Share failed: " + String(e?.message || e), "error"); }
762
834
  return;
763
835
  }
836
+ case "export": {
837
+ // Markdown transcript export — the plain-text counterpart to /share
838
+ // (which writes HTML). One file per session under .loom/exports/.
839
+ try {
840
+ const fsx = require("fs"); const pathx = require("path");
841
+ const dir = pathx.join(process.cwd(), ".loom", "exports");
842
+ fsx.mkdirSync(dir, { recursive: true });
843
+ const lines: string[] = [];
844
+ lines.push("# Loom Code session");
845
+ lines.push("");
846
+ lines.push("- Date: " + new Date().toISOString().slice(0, 19).replace("T", " "));
847
+ lines.push("- Model: " + providerName() + " / " + modelName());
848
+ lines.push("- Messages: " + messages().length);
849
+ lines.push("");
850
+ for (const m of messages()) {
851
+ const who = m.role === "user" ? "User" : m.role === "assistant" ? "Assistant" : "System";
852
+ lines.push("## " + who);
853
+ lines.push("");
854
+ const text = String(m.content ?? "").trim();
855
+ if (text) { lines.push(text); lines.push(""); }
856
+ if (Array.isArray(m.parts)) {
857
+ for (const p of m.parts) {
858
+ if (p.type === "tool" && p.tool) {
859
+ const t = p.tool;
860
+ let row = "- tool `" + t.name + "` " + (t.status || "");
861
+ const out = String(t.output || "").trim();
862
+ if (out) row += ": " + out.replace(/\s+/g, " ").slice(0, 300);
863
+ lines.push(row);
864
+ } else if (p.type === "text" && String(p.text || "").trim()) {
865
+ lines.push(String(p.text).trim());
866
+ lines.push("");
867
+ }
868
+ }
869
+ }
870
+ if (m.isError) lines.push("**(turn ended with an error)**", "");
871
+ lines.push("---");
872
+ lines.push("");
873
+ }
874
+ const f = pathx.join(dir, (sessionId() || "session") + ".md");
875
+ fsx.writeFileSync(f, lines.join("\n"));
876
+ showToast("Exported: " + f, "ok", 6000);
877
+ } catch (e: any) { showToast("Export failed: " + String(e?.message || e), "error"); }
878
+ return;
879
+ }
764
880
  case "worktree": {
765
881
  const name = String(args[0] || "").replace(/[^\w-]/g, "");
766
882
  if (!name) { showToast("Usage: /worktree <name>", "error"); return; }
@@ -979,6 +1095,63 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
979
1095
  });
980
1096
  return;
981
1097
  }
1098
+ case "undo": {
1099
+ // Step back one turn: restore the transcript captured before the last
1100
+ // user turn, and (when that step has a restore point) offer to rewind
1101
+ // the working tree too so files match the conversation.
1102
+ if (!_undo.canUndo()) { showToast("Nothing to undo.", "info"); return; }
1103
+ // Current state goes on the redo stack so the undo can be re-done.
1104
+ var curSnap = {
1105
+ ui: messages().slice(),
1106
+ session: getSession().messages.slice(),
1107
+ todos: (getSession().todos || []).slice(),
1108
+ restoreId: _lastRestoreId || undefined,
1109
+ };
1110
+ var target = _undo.undo(curSnap);
1111
+ if (!target) { showToast("Nothing to undo.", "info"); return; }
1112
+ setMessages(target.ui || []);
1113
+ getSession().messages = (target.session || []).slice();
1114
+ getSession().todos = (target.todos || []).slice();
1115
+ var undoLine = "Undone \u2014 transcript restored" + (target.label ? " to before: " + target.label.slice(0, 60) : "") + ".";
1116
+ if (target.restoreId) {
1117
+ _lastRestoreId = target.restoreId;
1118
+ undoLine += "\nAlso rewind the working tree to match? Run /undo-files to restore files from the same point.";
1119
+ }
1120
+ appendMessage({ role: "system", content: undoLine });
1121
+ try { saveSession(syncSessionForSave()); } catch {}
1122
+ refreshUsage();
1123
+ return;
1124
+ }
1125
+ case "redo": {
1126
+ if (!_undo.canRedo()) { showToast("Nothing to redo.", "info"); return; }
1127
+ var curSnap2 = {
1128
+ ui: messages().slice(),
1129
+ session: getSession().messages.slice(),
1130
+ todos: (getSession().todos || []).slice(),
1131
+ restoreId: _lastRestoreId || undefined,
1132
+ };
1133
+ var target2 = _undo.redo(curSnap2);
1134
+ if (!target2) { showToast("Nothing to redo.", "info"); return; }
1135
+ setMessages(target2.ui || []);
1136
+ getSession().messages = (target2.session || []).slice();
1137
+ getSession().todos = (target2.todos || []).slice();
1138
+ if (target2.restoreId) _lastRestoreId = target2.restoreId;
1139
+ appendMessage({ role: "system", content: "Redone \u2014 transcript restored" + (target2.label ? " to: " + target2.label.slice(0, 60) : "") + "." });
1140
+ try { saveSession(syncSessionForSave()); } catch {}
1141
+ refreshUsage();
1142
+ return;
1143
+ }
1144
+ case "undo-files": {
1145
+ // Roll the working tree back to the restore point bound to the most
1146
+ // recent undo, so files match the rewound conversation.
1147
+ if (!_lastRestoreId) { showToast("No file restore point bound to the last undo.", "error"); return; }
1148
+ var rr = restoreTo(_lastRestoreId);
1149
+ if (!rr.ok) { showToast("File restore failed: " + rr.error, "error"); return; }
1150
+ appendMessage({ role: "system", content: "Working tree rewound \u2014 " + rr.restored.length + " files restored, " + rr.deleted.length + " removed." });
1151
+ invalidateFilesCache();
1152
+ clearFileDiffs();
1153
+ return;
1154
+ }
982
1155
  case "usage": {
983
1156
  refreshUsage();
984
1157
  const { formatTokens, formatUsd, getUsage } = require("../core/usage.js");
@@ -1077,7 +1250,7 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
1077
1250
  }
1078
1251
  case "connectors": {
1079
1252
  var sub3 = args[0];
1080
- if (sub3 === "add") { appendMessage({ role: "system", content: plugin.mcpAddCmd(args.slice(1)) }); return; }
1253
+ if (sub3 === "add") { appendMessage({ role: "system", content: plugin.connectorAddCmd(args.slice(1)) }); return; }
1081
1254
  if (sub3 === "remove") { appendMessage({ role: "system", content: plugin.mcpRemoveCmd(args.slice(1)) }); return; }
1082
1255
  if (sub3 === "toggle") { appendMessage({ role: "system", content: plugin.mcpToggleCmd(args.slice(1)) }); return; }
1083
1256
  if (sub3 === "help") { appendMessage({ role: "system", content: plugin.mcpHelp() }); return; }
@@ -1089,39 +1262,60 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
1089
1262
  }
1090
1263
  case "sandbox": {
1091
1264
  var ssub = args[0];
1092
- var st = sandbox.sandboxStatus();
1093
1265
  // The setup wizard is a modal: docker check (with download link) →
1094
1266
  // OS pick → live build. Bare /sandbox and /sandbox setup open it.
1267
+ // Status/on/rm use ASYNC docker calls — sync ones froze the TUI.
1095
1268
  if (!ssub || ssub === "setup" || ssub === "os") { openModal({ type: "sandbox" }); return; }
1096
1269
  if (ssub === "guide") { appendMessage({ role: "system", content: sandbox.installGuide() }); return; }
1097
1270
  if (ssub === "off") {
1098
1271
  sandbox.setSandboxEnabled(false);
1099
- appendMessage({ role: "system", content: "Sandbox off — agent commands run on the host again. The container is kept (run /sandbox on to re-enable, /sandbox rm to delete it)." });
1272
+ appendMessage({ role: "system", content: "Sandbox off — the sandbox tool is disabled; the agent works on your machine as usual. The container is kept (run /sandbox on to re-enable, /sandbox rm to delete it)." });
1100
1273
  return;
1101
1274
  }
1102
1275
  if (ssub === "on") {
1103
- if (st.container !== "running") { appendMessage({ role: "system", content: "Sandbox container is not running. Run /sandbox setup first." }); return; }
1104
- sandbox.setSandboxEnabled(true);
1105
- appendMessage({ role: "system", content: "Sandbox on — agent commands now run inside " + sandbox.CONTAINER + "." });
1276
+ sandbox.sandboxStatusAsync().then(function(stOn) {
1277
+ if (stOn.container !== "running") { appendMessage({ role: "system", content: "Sandbox container is not running. Run /sandbox setup first." }); return; }
1278
+ sandbox.setSandboxEnabled(true);
1279
+ appendMessage({ role: "system", content: "Sandbox on — the agent can use the sandbox tool for isolated commands inside " + sandbox.CONTAINER + " when needed. Everything else stays on your machine." });
1280
+ });
1106
1281
  return;
1107
1282
  }
1108
1283
  if (ssub === "rm") {
1109
- try {
1110
- require("child_process").execFileSync("docker", ["rm", "-f", sandbox.CONTAINER], { windowsHide: true, timeout: 30000, stdio: "ignore" });
1111
- sandbox.setSandboxEnabled(false);
1112
- appendMessage({ role: "system", content: "Sandbox container removed. Run /sandbox setup to rebuild it." });
1113
- } catch { appendMessage({ role: "system", content: "No sandbox container to remove." }); }
1284
+ appendMessage({ role: "system", content: "Removing the sandbox container…" });
1285
+ var rmCp = require("child_process");
1286
+ var rmSpawn = rmCp.spawn("docker", ["rm", "-f", sandbox.CONTAINER], { windowsHide: true });
1287
+ var rmDone = false;
1288
+ var rmTimer = setTimeout(function() { if (!rmDone) { rmDone = true; try { rmSpawn.kill(); } catch {} appendMessage({ role: "system", content: "Sandbox removal timed out. Is Docker running?" }); } }, 30000);
1289
+ rmSpawn.on("close", function(rmCode) {
1290
+ if (rmDone) return;
1291
+ rmDone = true;
1292
+ clearTimeout(rmTimer);
1293
+ if (rmCode === 0) {
1294
+ sandbox.setSandboxEnabled(false);
1295
+ appendMessage({ role: "system", content: "Sandbox container removed. Run /sandbox setup to rebuild it." });
1296
+ } else {
1297
+ appendMessage({ role: "system", content: "No sandbox container to remove." });
1298
+ }
1299
+ });
1300
+ rmSpawn.on("error", function() {
1301
+ if (rmDone) return;
1302
+ rmDone = true;
1303
+ clearTimeout(rmTimer);
1304
+ appendMessage({ role: "system", content: "No sandbox container to remove (docker not reachable)." });
1305
+ });
1114
1306
  return;
1115
1307
  }
1116
1308
  if (ssub === "status") {
1117
- var line = "Sandbox: ";
1118
- if (st.docker === "not_installed") line += "Docker not installed";
1119
- else if (st.docker === "no_daemon") line += "Docker installed, daemon not running";
1120
- else line += "Docker " + st.dockerVersion + " \u00b7 container " + st.container + (st.enabled ? " \u00b7 routing ON" : " \u00b7 routing off");
1121
- if (st.pending) line += "\nWaiting for you to install Docker \u2014 when it's done say \"docker is ready\" (or run /sandbox).";
1122
- else if (st.docker === "not_installed") line += "\nRun /sandbox for the setup wizard.";
1123
- else if (st.container === "absent") line += "\nRun /sandbox to build it.";
1124
- appendMessage({ role: "system", content: line });
1309
+ sandbox.sandboxStatusAsync().then(function(st) {
1310
+ var line = "Sandbox: ";
1311
+ if (st.docker === "not_installed") line += "Docker not installed";
1312
+ else if (st.docker === "no_daemon") line += "Docker installed, daemon not running";
1313
+ else line += "Docker " + st.dockerVersion + " \u00b7 container " + st.container + (st.enabled ? " \u00b7 sandbox tool on" : " \u00b7 sandbox tool off");
1314
+ if (st.pending) line += "\nWaiting for you to install Docker \u2014 when it's done say \"docker is ready\" (or run /sandbox).";
1315
+ else if (st.docker === "not_installed") line += "\nRun /sandbox for the setup wizard.";
1316
+ else if (st.container === "absent") line += "\nRun /sandbox to build it.";
1317
+ appendMessage({ role: "system", content: line });
1318
+ });
1125
1319
  return;
1126
1320
  }
1127
1321
  appendMessage({ role: "system", content: "Usage: /sandbox (wizard) | status | on | off | rm | guide" });
@@ -1566,6 +1760,7 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
1566
1760
  // ═══════════════════════ Lifecycle ═══════════════════════
1567
1761
  let _skillToastOff: (() => void) | null = null;
1568
1762
  let _skillDoneOff: (() => void) | null = null;
1763
+ let _taskToastOff: (() => void) | null = null;
1569
1764
  onMount(function() {
1570
1765
  // Keep the renderer's frame pump alive. The engine only runs its
1571
1766
  // frame callback while there's a non-complete timeline registered;
@@ -1597,10 +1792,11 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
1597
1792
  }, 500);
1598
1793
  }
1599
1794
  // Sandbox install pending from a previous session: remind on start so
1600
- // the user knows to come back with "docker is ready". Skipped in the
1601
- // test harness (same flag as the session prompt).
1795
+ // the user knows to come back with "docker is ready". Config-only check
1796
+ // (no docker invocation — the old sandboxStatus() froze startup).
1797
+ // Skipped in the test harness (same flag as the session prompt).
1602
1798
  try {
1603
- if (!process.env.LOOM_NO_SESSION_PROMPT && sandbox.sandboxStatus().pending) {
1799
+ if (!process.env.LOOM_NO_SESSION_PROMPT && sandbox.sandboxPending()) {
1604
1800
  setTimeout(function() {
1605
1801
  appendMessage({ role: "system", content: "Docker sandbox setup is still pending. Install Docker (see /sandbox guide), then say \"docker is ready\" and I'll build it automatically." });
1606
1802
  }, 800);
@@ -1614,9 +1810,27 @@ export function App(props: { initialPrompt?: string; resumeSession?: string; aut
1614
1810
  _skillDoneOff = on("turn:end", function(d: any) {
1615
1811
  if (d?.skills?.length) showToast("skill handled: " + d.skills.join(", "), "ok");
1616
1812
  });
1813
+ // Background-task completion toasts: bash background:true tasks finishing
1814
+ // (done/error/killed) surface immediately so the model's "started a
1815
+ // server" isn't silent forever.
1816
+ var taskStatuses: Record<string, string> = {};
1817
+ _taskToastOff = on("tasks:changed", function() {
1818
+ try {
1819
+ var bt = require("../core/background-tasks.js");
1820
+ var rows = bt.listBackgroundTasks();
1821
+ for (var i = 0; i < rows.length; i++) {
1822
+ var t = rows[i];
1823
+ var prev = taskStatuses[t.id];
1824
+ taskStatuses[t.id] = t.status;
1825
+ if (prev === "running" && t.status !== "running") {
1826
+ showToast("Task " + t.id + " " + t.status + (t.exitCode != null ? " (exit " + t.exitCode + ")" : "") + " \u2014 /tasks for output", t.status === "done" ? "ok" : "error", 6000);
1827
+ }
1828
+ }
1829
+ } catch {}
1830
+ });
1617
1831
  (globalThis as any).__loomTrace?.("onMount-done", new Error("mount complete"));
1618
1832
  });
1619
- onCleanup(function() { persistUi(); if (_skillToastOff) _skillToastOff(); if (_skillDoneOff) _skillDoneOff(); });
1833
+ onCleanup(function() { persistUi(); if (_skillToastOff) _skillToastOff(); if (_skillDoneOff) _skillDoneOff(); if (_taskToastOff) _taskToastOff(); });
1620
1834
 
1621
1835
  var showSplash = createMemo(function() { return messages().length === 0; });
1622
1836