@circuitwall/jarela 0.9.3 → 0.10.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.
Files changed (94) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +2 -2
  3. package/.next/standalone/.next/build-manifest.json +2 -2
  4. package/.next/standalone/.next/prerender-manifest.json +3 -3
  5. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  15. package/.next/standalone/.next/server/app/_not-found.html +2 -2
  16. package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
  17. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  18. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  20. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  22. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/standalone/.next/server/app/api/v1/dashboard/metrics/route.js +72 -5
  24. package/.next/standalone/.next/server/app/api/v1/dashboard/metrics/route.js.map +1 -1
  25. package/.next/standalone/.next/server/app/api/v1/extensions/route.js +2 -2
  26. package/.next/standalone/.next/server/app/api/v1/extensions/tools/[name]/secrets/route.js +2 -2
  27. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +136 -26
  28. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js.map +1 -1
  29. package/.next/standalone/.next/server/app/api/v1/tools/route.js +2 -2
  30. package/.next/standalone/.next/server/app/index.html +2 -2
  31. package/.next/standalone/.next/server/app/index.rsc +3 -3
  32. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  33. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
  34. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  35. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +2 -2
  36. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  37. package/.next/standalone/.next/server/app/page.js +266 -40
  38. package/.next/standalone/.next/server/app/page.js.map +1 -1
  39. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  40. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/setup.html +1 -1
  42. package/.next/standalone/.next/server/app/setup.rsc +2 -2
  43. package/.next/standalone/.next/server/app/setup.segments/_full.segment.rsc +2 -2
  44. package/.next/standalone/.next/server/app/setup.segments/_head.segment.rsc +1 -1
  45. package/.next/standalone/.next/server/app/setup.segments/_index.segment.rsc +2 -2
  46. package/.next/standalone/.next/server/app/setup.segments/_tree.segment.rsc +2 -2
  47. package/.next/standalone/.next/server/app/setup.segments/setup/__PAGE__.segment.rsc +1 -1
  48. package/.next/standalone/.next/server/app/setup.segments/setup.segment.rsc +1 -1
  49. package/.next/standalone/.next/server/app-paths-manifest.json +2 -2
  50. package/.next/standalone/.next/server/chunks/210.js +1 -1
  51. package/.next/standalone/.next/server/chunks/2151.js +60 -2
  52. package/.next/standalone/.next/server/chunks/2151.js.map +1 -1
  53. package/.next/standalone/.next/server/chunks/614.js +336 -93
  54. package/.next/standalone/.next/server/chunks/614.js.map +1 -1
  55. package/.next/standalone/.next/server/chunks/6765.js +35 -0
  56. package/.next/standalone/.next/server/chunks/6765.js.map +1 -1
  57. package/.next/standalone/.next/server/chunks/8697.js +15246 -15002
  58. package/.next/standalone/.next/server/chunks/8697.js.map +1 -1
  59. package/.next/standalone/.next/server/middleware-build-manifest.js +2 -2
  60. package/.next/standalone/.next/server/pages/404.html +2 -2
  61. package/.next/standalone/.next/server/pages/500.html +1 -1
  62. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  63. package/.next/standalone/.next/static/chunks/{3741-344e2bfc5028b9c8.js → 3741-2d64471ff763b8fa.js} +36 -1
  64. package/.next/standalone/.next/static/chunks/3741-2d64471ff763b8fa.js.map +1 -0
  65. package/.next/standalone/.next/static/chunks/app/{page-c77ab600642bbfc2.js → page-318743bf47fac345.js} +267 -41
  66. package/.next/standalone/.next/static/chunks/app/page-318743bf47fac345.js.map +1 -0
  67. package/.next/standalone/.next/static/css/b6b85b0f13bc0e98.css +5 -0
  68. package/.next/standalone/.next/static/css/b6b85b0f13bc0e98.css.map +1 -0
  69. package/.next/standalone/package.json +1 -1
  70. package/CHANGELOG.md +48 -0
  71. package/README.md +2 -0
  72. package/api/client.ts +37 -1
  73. package/api/types.ts +18 -0
  74. package/app/api/v1/threads/[thread_id]/run/route.ts +69 -22
  75. package/components/agents/AgentEditor.tsx +7 -4
  76. package/components/chat/MessageBubble.tsx +108 -1
  77. package/components/dashboard/DashboardPanel.tsx +79 -21
  78. package/hooks/useSSE.ts +22 -9
  79. package/lib/agents/prepare/system-prompt.ts +30 -0
  80. package/lib/agents/run-registry.test.ts +94 -0
  81. package/lib/agents/run-registry.ts +60 -1
  82. package/lib/stores/dashboard-metrics.test.ts +33 -0
  83. package/lib/stores/dashboard-metrics.ts +93 -1
  84. package/lib/tools/exec.ts +9 -5
  85. package/lib/tools/files.ts +6 -0
  86. package/lib/tools/safety.test.ts +95 -0
  87. package/lib/tools/safety.ts +147 -0
  88. package/package.json +1 -1
  89. package/.next/standalone/.next/static/chunks/3741-344e2bfc5028b9c8.js.map +0 -1
  90. package/.next/standalone/.next/static/chunks/app/page-c77ab600642bbfc2.js.map +0 -1
  91. package/.next/standalone/.next/static/css/53f85613a5500253.css +0 -5
  92. package/.next/standalone/.next/static/css/53f85613a5500253.css.map +0 -1
  93. /package/.next/standalone/.next/static/{6uLoytvvEtLKIblEB53e0 → 8qTBpUDFnSMYwe3Zc0bGV}/_buildManifest.js +0 -0
  94. /package/.next/standalone/.next/static/{6uLoytvvEtLKIblEB53e0 → 8qTBpUDFnSMYwe3Zc0bGV}/_ssgManifest.js +0 -0
@@ -88,28 +88,60 @@ export async function POST(req: NextRequest, { params }: Params) {
88
88
  // Drive the agent to completion regardless of client connection. Events
89
89
  // go to the registry; the GET subscriber (and any reattaching clients)
90
90
  // receive them via subscribe().
91
+ //
92
+ // CRITICAL: finishRun() MUST run no matter what — if it doesn't, the
93
+ // run is pinned as "running" forever, every subsequent POST returns
94
+ // 409 run_in_flight, and the user sees a silently-dead chat. The TTL
95
+ // eviction is scheduled inside finishRun(), so a leaked entry never
96
+ // self-heals. Wrap the whole body in try/finally.
91
97
  void (async () => {
92
- const collected = await collectStream(prepared.stream as AsyncIterable<StreamChunk>, {
93
- onChunk: (chunk) => broadcast(active, chunk),
94
- });
95
- // If the stream threw mid-iteration, collectStream returns terminal="error"
96
- // but no `error` chunk was broadcast — surface one to subscribers.
97
- if (collected.terminal === "error" && collected.errorMessage) {
98
+ let terminal: "done" | "error" = "error";
99
+ let assistantContent = "";
100
+ try {
101
+ const collected = await collectStream(prepared.stream as AsyncIterable<StreamChunk>, {
102
+ onChunk: (chunk) => broadcast(active, chunk),
103
+ });
104
+ assistantContent = collected.assistantContent;
105
+ terminal = collected.terminal;
106
+ // If the stream threw mid-iteration, collectStream returns terminal="error"
107
+ // but no `error` chunk was broadcast — surface one to subscribers.
108
+ if (collected.terminal === "error" && collected.errorMessage) {
109
+ broadcast(active, {
110
+ type: "error",
111
+ data: { message: collected.errorMessage, code: "stream_error" },
112
+ });
113
+ }
114
+ try {
115
+ persistAssistantMessage(thread_id, collected.assistantContent, collected.usedTools, collected.toolEvents, null, collected.usage ?? null, prepared.context_snapshot ?? null);
116
+ } catch (persistErr) {
117
+ // Persistence failure must not strand the run — surface and continue
118
+ // to finishRun in the finally block.
119
+ terminal = "error";
120
+ broadcast(active, {
121
+ type: "error",
122
+ data: { message: `persist failed: ${(persistErr as Error).message}`, code: "persist_error" },
123
+ });
124
+ }
125
+ } catch (err) {
126
+ // Unhandled throw from collectStream / the underlying stream. Without
127
+ // this catch, finishRun would never run and the registry entry would
128
+ // stick as "running" indefinitely (see CRITICAL note above).
129
+ terminal = "error";
98
130
  broadcast(active, {
99
131
  type: "error",
100
- data: { message: collected.errorMessage, code: "stream_error" },
132
+ data: { message: (err as Error).message ?? String(err), code: "run_crashed" },
133
+ });
134
+ } finally {
135
+ finishRun(active, terminal);
136
+ publishNotification({
137
+ type: "run_completed",
138
+ thread_id,
139
+ agent_id: thread?.agent_id ?? null,
140
+ status: terminal,
141
+ preview: assistantContent.replace(/\s+/g, " ").trim().slice(0, 120),
142
+ ts: Date.now(),
101
143
  });
102
144
  }
103
- persistAssistantMessage(thread_id, collected.assistantContent, collected.usedTools, collected.toolEvents, null, collected.usage ?? null, prepared.context_snapshot ?? null);
104
- finishRun(active, collected.terminal);
105
- publishNotification({
106
- type: "run_completed",
107
- thread_id,
108
- agent_id: thread?.agent_id ?? null,
109
- status: collected.terminal,
110
- preview: collected.assistantContent.replace(/\s+/g, " ").trim().slice(0, 120),
111
- ts: Date.now(),
112
- });
113
145
  })();
114
146
 
115
147
  return new Response(
@@ -124,8 +156,14 @@ export async function POST(req: NextRequest, { params }: Params) {
124
156
  // `EventSource` — never `fetch().body.getReader()`, which is unreliable on
125
157
  // iOS Safari for long-lived streaming responses.
126
158
  //
127
- // Returns 404 with no body if there's no run to attach to (run never
128
- // existed, or it finished + TTL-evicted before the GET arrived).
159
+ // Emits a single synthetic `done` event and closes when there's no run to
160
+ // attach to (run never existed, or it finished + TTL-evicted before the GET
161
+ // arrived). We deliberately do NOT return 404 here: browsers map a 404 SSE
162
+ // response onto `EventSource.onerror` with no terminal event, which trips
163
+ // EventSource's auto-reconnect logic and leaves the client in a "stream
164
+ // open but silent" state — UIs gating on a `done` event hang forever
165
+ // showing the Stop/Reconnecting affordances. A 200 with `data: {"type":
166
+ // "done"}\n\n` makes the iterator drain cleanly on every transport.
129
167
  //
130
168
  // `show_tools` / `show_thinking` query params let the caller suppress
131
169
  // chunk types it doesn't want to render. Defaults: both on. The full
@@ -133,14 +171,23 @@ export async function POST(req: NextRequest, { params }: Params) {
133
171
  // agent run config are run-wide settings, not per-subscriber filters).
134
172
  export async function GET(req: NextRequest, { params }: Params) {
135
173
  const { thread_id } = await params;
136
- const run = getRun(thread_id);
137
- if (!run) return new Response(null, { status: 404 });
138
-
139
174
  const showTools = req.nextUrl.searchParams.get("show_tools") !== "false";
140
175
  const showThinking = req.nextUrl.searchParams.get("show_thinking") !== "false";
141
176
  const stream_options: StreamOptions = {
142
177
  filters: { include_tools: showTools, include_thinking: showThinking },
143
178
  };
179
+
180
+ const run = getRun(thread_id);
181
+ if (!run) {
182
+ const stream = new ReadableStream({
183
+ start(controller) {
184
+ controller.enqueue(sse({ type: "done" }));
185
+ controller.close();
186
+ },
187
+ });
188
+ return sseResponse(stream);
189
+ }
190
+
144
191
  return attachStream(thread_id, stream_options);
145
192
  }
146
193
 
@@ -807,7 +807,9 @@ function ToolGroupBlock({
807
807
  const selectedInGroup = allTools.filter((t) => selected.includes(t.name)).length;
808
808
  const allOn = selectedInGroup === allTools.length;
809
809
  const someOn = selectedInGroup > 0 && !allOn;
810
- const [open, setOpen] = useState(selectedInGroup > 0);
810
+ // Collapsed by default to keep the editor compact on small / PWA viewports;
811
+ // the header still surfaces the selected/total count.
812
+ const [open, setOpen] = useState(false);
811
813
  const headerRef = useRef<HTMLInputElement>(null);
812
814
 
813
815
  useEffect(() => {
@@ -844,8 +846,8 @@ function ToolGroupBlock({
844
846
 
845
847
  // Collapsible per-category block with a tri-state header checkbox. The
846
848
  // header toggle flips the entire category on/off; individual tool checkboxes
847
- // stay available for fine-grained control. Collapsed-by-default when no tool
848
- // in the category is selected, to keep the editor compact.
849
+ // stay available for fine-grained control. Collapsed by default to keep the
850
+ // editor short on mobile / PWA viewports.
849
851
  function ToolCategoryBlock({
850
852
  category,
851
853
  tools,
@@ -866,7 +868,8 @@ function ToolCategoryBlock({
866
868
  const selectedInCat = tools.filter((t) => selected.includes(t.name)).length;
867
869
  const allOn = selectedInCat === tools.length;
868
870
  const someOn = selectedInCat > 0 && !allOn;
869
- const [open, setOpen] = useState(selectedInCat > 0);
871
+ // Collapsed by default see ToolGroupBlock comment.
872
+ const [open, setOpen] = useState(false);
870
873
  const headerRef = useRef<HTMLInputElement>(null);
871
874
 
872
875
  // Render the tri-state indeterminate dash via the DOM property (React
@@ -7,7 +7,7 @@ import rehypeRaw from "rehype-raw";
7
7
  import rehypeHighlight from "rehype-highlight";
8
8
  import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
9
9
  import "highlight.js/styles/github-dark.css";
10
- import { Bot, Check, ChevronRight, Copy, Link as LinkIcon, Link2, Loader2, MessageCircle, Paperclip, Pause, Play, User, Users, X } from "lucide-react";
10
+ import { Bot, Check, ChevronRight, Clock, Copy, Eye, EyeOff, Link as LinkIcon, Link2, Loader2, MessageCircle, Paperclip, Pause, Play, User, Users, X } from "lucide-react";
11
11
  import type { AgentConfig, Message, UserProfile } from "@/api/types";
12
12
  import type { ContentPart } from "@/api/types";
13
13
  import { ToolList } from "@/components/chat/ToolList";
@@ -409,6 +409,110 @@ function UserAvatar({ profile }: { profile?: UserProfile | null }) {
409
409
  // side changes, update both.
410
410
  const parseBridgeContext = parseBridgePrompt;
411
411
 
412
+ const SILENT_TRIGGER_RE = /\n+\[SILENT_TRIGGER\][^\n]*(?:\n(?!\n).*)*$/;
413
+
414
+ interface ScheduledTaskCardData {
415
+ kind: "scheduled_task";
416
+ prompt: string;
417
+ silent: boolean;
418
+ }
419
+
420
+ interface WatcherCardData {
421
+ kind: "watcher";
422
+ label: string;
423
+ tool: string;
424
+ args: string;
425
+ diff: string;
426
+ directive: string;
427
+ silent: boolean;
428
+ }
429
+
430
+ type TriggerCardData = ScheduledTaskCardData | WatcherCardData;
431
+
432
+ // Strip the `[SILENT_TRIGGER] …` envelope that runTriggerAgent appends
433
+ // in silent mode so the card can render the user's original prompt and
434
+ // surface a separate "Silent" pill instead of leaking framework prose.
435
+ function stripSilentEnvelope(text: string): { text: string; silent: boolean } {
436
+ const m = SILENT_TRIGGER_RE.exec(text);
437
+ if (!m) return { text, silent: false };
438
+ return { text: text.slice(0, m.index).trimEnd(), silent: true };
439
+ }
440
+
441
+ function parseTriggerMessage(category: string | null | undefined, raw: string): TriggerCardData | null {
442
+ if (category === "scheduled_task") {
443
+ const { text, silent } = stripSilentEnvelope(raw);
444
+ return { kind: "scheduled_task", prompt: text, silent };
445
+ }
446
+ if (category === "watcher") {
447
+ const { text, silent } = stripSilentEnvelope(raw);
448
+ const headerRe = /^Watcher\s+"([^"]*)"\s+detected a change\.\s*\n+Tool:\s*([^\n]+)\s*\nArgs:\s*([\s\S]*?)\n+---\s*Diff[^\n]*---\s*\n([\s\S]*?)\n\n([\s\S]*)$/;
449
+ const m = headerRe.exec(text);
450
+ if (!m) return null;
451
+ return {
452
+ kind: "watcher",
453
+ label: m[1],
454
+ tool: m[2].trim(),
455
+ args: m[3].trim(),
456
+ diff: m[4].trim(),
457
+ directive: m[5].trim(),
458
+ silent,
459
+ };
460
+ }
461
+ return null;
462
+ }
463
+
464
+ // Compact header card for trigger-originated user messages (scheduled
465
+ // tasks + watchers, ADR-0027/ADR-0032). Surfaces the trigger type with
466
+ // an icon + label so the operator can tell at a glance that the prompt
467
+ // came from automation and not from them. Watchers additionally expose
468
+ // the diff context in a collapsed section to keep large diffs out of
469
+ // the main bubble height.
470
+ function TriggerMessageCard({ data }: { data: TriggerCardData }) {
471
+ const [diffOpen, setDiffOpen] = useState(false);
472
+ const Icon = data.kind === "scheduled_task" ? Clock : Eye;
473
+ const label = data.kind === "scheduled_task" ? "Scheduled task" : `Watcher: ${data.label || "(unnamed)"}`;
474
+ return (
475
+ <div className="flex flex-col gap-1.5 min-w-0">
476
+ <div className="flex items-center gap-1.5 text-[11px] text-white/85 min-w-0">
477
+ <Icon size={11} className="shrink-0" />
478
+ <span className="font-medium truncate">{label}</span>
479
+ {data.kind === "watcher" && (
480
+ <span className="px-1.5 py-0.5 rounded-full bg-white/15 text-[9.5px] uppercase tracking-wide shrink-0">
481
+ {data.tool}
482
+ </span>
483
+ )}
484
+ {data.silent && (
485
+ <span className="flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-white/15 text-[9.5px] uppercase tracking-wide shrink-0" title="Silent trigger: reply only if material">
486
+ <EyeOff size={9} />
487
+ silent
488
+ </span>
489
+ )}
490
+ </div>
491
+ {data.kind === "watcher" && (
492
+ <button
493
+ type="button"
494
+ onClick={() => setDiffOpen((v) => !v)}
495
+ className="flex items-center gap-1 text-left text-[11px] text-white/75 hover:text-white/95"
496
+ aria-expanded={diffOpen}
497
+ title={diffOpen ? "Hide diff" : "Show diff"}
498
+ >
499
+ <ChevronRight size={11} className={`shrink-0 transition-transform ${diffOpen ? "rotate-90" : ""}`} />
500
+ <span>Change context</span>
501
+ </button>
502
+ )}
503
+ {data.kind === "watcher" && diffOpen && (
504
+ <div className="ml-4 flex flex-col gap-1">
505
+ <pre className="m-0 px-2 py-1.5 rounded bg-black/25 text-[11px] leading-snug whitespace-pre-wrap break-words text-white/90 max-h-72 overflow-auto">{data.args}</pre>
506
+ <pre className="m-0 px-2 py-1.5 rounded bg-black/25 text-[11px] leading-snug whitespace-pre-wrap break-words text-white/90 max-h-96 overflow-auto">{data.diff}</pre>
507
+ </div>
508
+ )}
509
+ <p className="whitespace-pre-wrap text-[13.5px] leading-relaxed">
510
+ {data.kind === "scheduled_task" ? data.prompt : data.directive}
511
+ </p>
512
+ </div>
513
+ );
514
+ }
515
+
412
516
  // Compact header card for inbound bridge messages. Shows sender + chat
413
517
  // context as a single line of metadata above the actual message text, so a
414
518
  // WhatsApp DM looks like "Alice • DM\n<text>" and a group message looks
@@ -1099,6 +1203,9 @@ export const MessageBubble = memo(function MessageBubble({ message, agentConfig,
1099
1203
  {typeof parsed === "string" ? (
1100
1204
  isUser ? (
1101
1205
  (() => {
1206
+ const category = "category" in message ? message.category : null;
1207
+ const trigger = parseTriggerMessage(category, parsed);
1208
+ if (trigger) return <TriggerMessageCard data={trigger} />;
1102
1209
  const bridge = parseBridgeContext(parsed);
1103
1210
  if (bridge) return <BridgeMessageCard ctx={bridge} />;
1104
1211
  const ctx = parseCapturedContext(parsed);
@@ -388,18 +388,9 @@ export function DashboardPanel() {
388
388
  : "Currency defaults to USD because no location-based currency is available."}
389
389
  </p>
390
390
 
391
- <section className="rounded-2xl border border-[var(--border)] bg-[var(--bg-secondary)]/90 p-4 shadow-sm">
392
- <h3 className="text-sm font-medium text-[var(--text-primary)] mb-3">Token usage over time</h3>
393
- <InteractiveTokenChart
394
- series={series}
395
- selectedDay={selectedDay}
396
- onSelectDay={(day) => setSelectedDay((prev) => (prev === day ? null : day))}
397
- />
398
- </section>
399
-
400
391
  <section className="rounded-2xl border border-[var(--border)] bg-[var(--bg-secondary)]/90 p-4 shadow-sm">
401
392
  <div className="mb-3 flex flex-wrap items-center justify-between gap-2">
402
- <h3 className="text-sm font-medium text-[var(--text-primary)]">Estimated cost over time</h3>
393
+ <h3 className="text-sm font-medium text-[var(--text-primary)]">Tokens &amp; cost over time</h3>
403
394
  {selectedDay ? (
404
395
  <button
405
396
  type="button"
@@ -414,12 +405,19 @@ export function DashboardPanel() {
414
405
  <span className="text-[11px] text-[var(--text-secondary)]">Click a day to narrow the breakdown</span>
415
406
  )}
416
407
  </div>
417
- <InteractiveCostChart
408
+ <InteractiveTokenChart
418
409
  series={series}
419
- currencyInfo={currencyInfo}
420
410
  selectedDay={selectedDay}
421
411
  onSelectDay={(day) => setSelectedDay((prev) => (prev === day ? null : day))}
422
412
  />
413
+ <div className="mt-3 border-t border-[var(--border)]/60 pt-3">
414
+ <InteractiveCostChart
415
+ series={series}
416
+ currencyInfo={currencyInfo}
417
+ selectedDay={selectedDay}
418
+ onSelectDay={(day) => setSelectedDay((prev) => (prev === day ? null : day))}
419
+ />
420
+ </div>
423
421
  </section>
424
422
 
425
423
  <div className="grid md:grid-cols-2 gap-4">
@@ -670,6 +668,14 @@ function InsightChip({ label, value, hint }: { label: string; value: string; hin
670
668
  );
671
669
  }
672
670
 
671
+ // Per-tier breakdown colors shared by the stacked token chart legend.
672
+ const TIER_COLORS: Record<"hot" | "warm" | "facts" | "overhead", string> = {
673
+ hot: "#22d3ee",
674
+ warm: "#f59e0b",
675
+ facts: "#a78bfa",
676
+ overhead: "#94a3b8",
677
+ };
678
+
673
679
  type DonutSlice = {
674
680
  id: string;
675
681
  label: string;
@@ -1033,6 +1039,15 @@ function InteractiveTokenChart({
1033
1039
  const totalHeight = hasData && maxTotal > 0 ? Math.max(4, Math.round((total / maxTotal) * 150)) : 0;
1034
1040
  const inputHeight = hasData ? Math.round((point.input_tokens_est / total) * totalHeight) : 0;
1035
1041
  const outputHeight = hasData ? Math.max(0, totalHeight - inputHeight) : 0;
1042
+ // Subdivide the input portion by measured tier breakdown when the
1043
+ // day has at least one snapshotted assistant turn. Legacy days
1044
+ // (measured_input_tokens === 0) fall back to a solid violet block.
1045
+ const tier = point.tier_tokens;
1046
+ const tierTotal = tier?.measured_input_tokens ?? 0;
1047
+ const hotPx = tierTotal > 0 ? Math.round((tier.hot_tokens / tierTotal) * inputHeight) : 0;
1048
+ const warmPx = tierTotal > 0 ? Math.round((tier.warm_tokens / tierTotal) * inputHeight) : 0;
1049
+ const factsPx = tierTotal > 0 ? Math.round((tier.facts_tokens / tierTotal) * inputHeight) : 0;
1050
+ const overheadPx = tierTotal > 0 ? Math.max(0, inputHeight - hotPx - warmPx - factsPx) : 0;
1036
1051
  const isActive = idx === hovered;
1037
1052
  const isSelected = selectedDay === point.day;
1038
1053
  const clickable = !!onSelectDay && hasData;
@@ -1070,13 +1085,50 @@ function InteractiveTokenChart({
1070
1085
  transition: `height 420ms cubic-bezier(0.2, 0.8, 0.2, 1) ${idx * 18}ms`,
1071
1086
  }}
1072
1087
  />
1073
- <div
1074
- className="w-full bg-gradient-to-t from-indigo-500 to-violet-400"
1075
- style={{
1076
- height: barsReady ? `${inputHeight}px` : "0px",
1077
- transition: `height 420ms cubic-bezier(0.2, 0.8, 0.2, 1) ${idx * 18 + 30}ms`,
1078
- }}
1079
- />
1088
+ {tierTotal > 0 ? (
1089
+ <>
1090
+ <div
1091
+ className="w-full"
1092
+ style={{
1093
+ background: TIER_COLORS.hot,
1094
+ height: barsReady ? `${hotPx}px` : "0px",
1095
+ transition: `height 420ms cubic-bezier(0.2, 0.8, 0.2, 1) ${idx * 18 + 30}ms`,
1096
+ }}
1097
+ />
1098
+ <div
1099
+ className="w-full"
1100
+ style={{
1101
+ background: TIER_COLORS.warm,
1102
+ height: barsReady ? `${warmPx}px` : "0px",
1103
+ transition: `height 420ms cubic-bezier(0.2, 0.8, 0.2, 1) ${idx * 18 + 42}ms`,
1104
+ }}
1105
+ />
1106
+ <div
1107
+ className="w-full"
1108
+ style={{
1109
+ background: TIER_COLORS.facts,
1110
+ height: barsReady ? `${factsPx}px` : "0px",
1111
+ transition: `height 420ms cubic-bezier(0.2, 0.8, 0.2, 1) ${idx * 18 + 54}ms`,
1112
+ }}
1113
+ />
1114
+ <div
1115
+ className="w-full"
1116
+ style={{
1117
+ background: TIER_COLORS.overhead,
1118
+ height: barsReady ? `${overheadPx}px` : "0px",
1119
+ transition: `height 420ms cubic-bezier(0.2, 0.8, 0.2, 1) ${idx * 18 + 66}ms`,
1120
+ }}
1121
+ />
1122
+ </>
1123
+ ) : (
1124
+ <div
1125
+ className="w-full bg-gradient-to-t from-indigo-500 to-violet-400"
1126
+ style={{
1127
+ height: barsReady ? `${inputHeight}px` : "0px",
1128
+ transition: `height 420ms cubic-bezier(0.2, 0.8, 0.2, 1) ${idx * 18 + 30}ms`,
1129
+ }}
1130
+ />
1131
+ )}
1080
1132
  </>
1081
1133
  ) : null}
1082
1134
  </div>
@@ -1089,11 +1141,17 @@ function InteractiveTokenChart({
1089
1141
  </div>
1090
1142
  </div>
1091
1143
  <div className="mt-2 flex flex-wrap items-center gap-3 text-[11px] text-[var(--text-secondary)]">
1092
- <span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-violet-400" />input</span>
1144
+ <span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: TIER_COLORS.hot }} />hot</span>
1145
+ <span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: TIER_COLORS.warm }} />warm</span>
1146
+ <span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: TIER_COLORS.facts }} />facts</span>
1147
+ <span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: TIER_COLORS.overhead }} />overhead</span>
1148
+ <span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-violet-400" />input (est)</span>
1093
1149
  <span className="inline-flex items-center gap-1"><span className="w-2 h-2 rounded-full bg-amber-400" />output</span>
1094
1150
  {active && (
1095
1151
  <span className="ml-auto text-[var(--text-primary)]">
1096
- in {formatInt(active.input_tokens_est)} · out {formatInt(active.output_tokens_est)}
1152
+ {active.tier_tokens.measured_input_tokens > 0
1153
+ ? `hot ${formatInt(active.tier_tokens.hot_tokens)} · warm ${formatInt(active.tier_tokens.warm_tokens)} · facts ${formatInt(active.tier_tokens.facts_tokens)} · overhead ${formatInt(active.tier_tokens.overhead_tokens)} · out ${formatInt(active.output_tokens_est)}`
1154
+ : `in ${formatInt(active.input_tokens_est)} · out ${formatInt(active.output_tokens_est)}`}
1097
1155
  </span>
1098
1156
  )}
1099
1157
  </div>
package/hooks/useSSE.ts CHANGED
@@ -126,11 +126,21 @@ export function useSSE(onDone?: () => void) {
126
126
  if ((err as Error).name !== "AbortError") {
127
127
  setError(String(err));
128
128
  }
129
+ return { accepted: false };
130
+ } finally {
131
+ // Always release the gate when the stream ends — defends against the
132
+ // consume() loop returning without ever observing a terminal `done`
133
+ // event (e.g. the EventSource closed cleanly with zero events). If
134
+ // we relied solely on the `done` branch inside consume(), the chat
135
+ // would stay locked behind the Stop button forever.
136
+ //
137
+ // We intentionally do NOT clear streamingContent / thinkingContent
138
+ // here — the consumer (ChatView) swaps them for the persisted
139
+ // assistant bubble once the refetch lands; clearing now would yank
140
+ // the text out from under the user. The next start()/attach() resets
141
+ // them.
129
142
  setStreaming(false);
130
- setStreamingContent("");
131
- setThinkingContent("");
132
143
  closeActivity();
133
- return { accepted: false };
134
144
  }
135
145
  }, [consume, openActivity, closeActivity]);
136
146
 
@@ -179,15 +189,18 @@ export function useSSE(onDone?: () => void) {
179
189
  try {
180
190
  await consume(subscribeRun(threadId, ctrl.signal));
181
191
  } catch (err) {
182
- // Attach failures are non-fatal — clear the gate and let the consumer
183
- // drain anything queued during session load. The common case is
184
- // "no run to attach to" (server returns 404, EventSource fails to
185
- // open) — completely normal when navigating into an idle session.
186
- setStreaming(false);
187
- closeActivity();
188
192
  if ((err as Error).name !== "AbortError") {
189
193
  onDone?.();
190
194
  }
195
+ } finally {
196
+ // Always release the optimistic gate when the stream ends — whether
197
+ // via terminal `done`/`error` event, a thrown failure, or a clean
198
+ // EventSource close with no events (idle thread, server returned 404
199
+ // so the iterator exited without yielding). Without this, navigating
200
+ // into an idle thread leaves streaming=true forever: the Stop button
201
+ // hangs in the composer and the "Reconnecting…" badge never clears.
202
+ setStreaming(false);
203
+ closeActivity();
191
204
  }
192
205
  }, [consume, onDone, openActivity, closeActivity]);
193
206
 
@@ -10,6 +10,7 @@ import os from "node:os";
10
10
  import type { AgentConfigRow } from "@/lib/stores/agent-configs";
11
11
  import { getUserProfile } from "@/lib/stores/user-profile";
12
12
  import { listIntegrations } from "@/lib/stores/integrations";
13
+ import { listEnabledDocumentSources, getDocumentSourceStats } from "@/lib/stores/document-sources";
13
14
  import { buildAdaptivePersonaContext } from "@/lib/agents/adaptive-persona";
14
15
  import { resolveHarness } from "@/lib/agents/harness/resolve";
15
16
  import {
@@ -49,6 +50,7 @@ export function buildSystemPrompt(ctx: SystemPromptContext): string {
49
50
  adaptivePersonaCtx,
50
51
  buildUserContext(),
51
52
  buildIntegrationsContext(),
53
+ buildDocumentsContext(),
52
54
  harnessParts.capabilities,
53
55
  harnessParts.plan_first,
54
56
  harnessParts.presentation,
@@ -203,3 +205,31 @@ function buildDelegatesContext(lines: string[]): string {
203
205
  ...lines,
204
206
  ].join("\n");
205
207
  }
208
+
209
+ // Surface indexed Documents so the model knows the RAG corpus exists.
210
+ // Without this nudge agents almost never call `documents_search` — they have
211
+ // no signal that any local content is searchable. Gated on actually-indexed
212
+ // chunks (not just configured sources) so an empty/erroring source doesn't
213
+ // produce false advertising.
214
+ function buildDocumentsContext(): string {
215
+ const sources = listEnabledDocumentSources();
216
+ if (sources.length === 0) return "";
217
+
218
+ let totalChunks = 0;
219
+ const lines: string[] = [];
220
+ for (const s of sources) {
221
+ const stats = getDocumentSourceStats(s.id);
222
+ if (stats.chunk_count === 0) continue;
223
+ totalChunks += stats.chunk_count;
224
+ const label = s.label ?? s.path;
225
+ lines.push(`- ${label} (${s.kind}, ${stats.chunk_count} chunks)`);
226
+ }
227
+ if (totalChunks === 0) return "";
228
+
229
+ return [
230
+ "--- Indexed documents ---",
231
+ `The user has ${totalChunks} indexed chunks across ${lines.length} document source(s) available to you:`,
232
+ ...lines,
233
+ "Call `documents_search` whenever the user asks about local files, notes, project docs, or any content that sounds like it lives in one of these sources. Prefer it over guessing from training data. Use `documents_list_sources` to enumerate, and pass `source_id` to scope a search.",
234
+ ].join("\n");
235
+ }
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { startRun, finishRun, getRun, broadcast } from "./run-registry";
3
+ import type { StreamChunk } from "./base";
4
+
5
+ const delta = (s: string): StreamChunk => ({ type: "text_delta", data: { delta: s } } as StreamChunk);
6
+
7
+ describe("run-registry watchdog", () => {
8
+ beforeEach(() => {
9
+ vi.useFakeTimers();
10
+ });
11
+ afterEach(() => {
12
+ vi.useRealTimers();
13
+ });
14
+
15
+ it("force-evicts a leaked 'running' entry after JARELA_RUN_MAX_MS", () => {
16
+ const prev = process.env.JARELA_RUN_MAX_MS;
17
+ process.env.JARELA_RUN_MAX_MS = "60000";
18
+ try {
19
+ const tid = `t-leak-${Date.now()}`;
20
+ const run = startRun(tid, null);
21
+ expect(getRun(tid)).toBe(run);
22
+ expect(run.status).toBe("running");
23
+
24
+ // Driver never calls finishRun(). Without the watchdog the entry
25
+ // would sit as "running" forever, 409'ing every subsequent submit.
26
+ vi.advanceTimersByTime(60_000 + 10);
27
+
28
+ expect(run.status).toBe("error");
29
+ // Still in the map briefly (TTL-evicted 5 min after finishRun),
30
+ // but no longer blocks new runs because status is no longer "running".
31
+ const stillThere = getRun(tid);
32
+ expect(stillThere === null || stillThere.status === "error").toBe(true);
33
+
34
+ // A fresh startRun for the same thread must now succeed.
35
+ expect(() => startRun(tid, null)).not.toThrow();
36
+ } finally {
37
+ if (prev === undefined) delete process.env.JARELA_RUN_MAX_MS;
38
+ else process.env.JARELA_RUN_MAX_MS = prev;
39
+ }
40
+ });
41
+
42
+ it("does not clobber a run that finishes normally before the watchdog", () => {
43
+ const prev = process.env.JARELA_RUN_MAX_MS;
44
+ process.env.JARELA_RUN_MAX_MS = "60000";
45
+ try {
46
+ const tid = `t-ok-${Date.now()}`;
47
+ const run = startRun(tid, null);
48
+ finishRun(run, "done");
49
+ expect(run.status).toBe("done");
50
+ vi.advanceTimersByTime(60_000 + 10);
51
+ // Watchdog must not have flipped a "done" run to "error".
52
+ expect(run.status).toBe("done");
53
+ } finally {
54
+ if (prev === undefined) delete process.env.JARELA_RUN_MAX_MS;
55
+ else process.env.JARELA_RUN_MAX_MS = prev;
56
+ }
57
+ });
58
+
59
+ it("force-finishes a stalled run after JARELA_RUN_IDLE_MS of no progress", () => {
60
+ const prev = process.env.JARELA_RUN_IDLE_MS;
61
+ process.env.JARELA_RUN_IDLE_MS = "5000";
62
+ try {
63
+ const tid = `t-idle-${Date.now()}`;
64
+ const run = startRun(tid, null);
65
+ vi.advanceTimersByTime(5_000 + 10);
66
+ expect(run.status).toBe("error");
67
+ } finally {
68
+ if (prev === undefined) delete process.env.JARELA_RUN_IDLE_MS;
69
+ else process.env.JARELA_RUN_IDLE_MS = prev;
70
+ }
71
+ });
72
+
73
+ it("does not fire idle watchdog while broadcast keeps streaming", () => {
74
+ const prev = process.env.JARELA_RUN_IDLE_MS;
75
+ process.env.JARELA_RUN_IDLE_MS = "5000";
76
+ try {
77
+ const tid = `t-stream-${Date.now()}`;
78
+ const run = startRun(tid, null);
79
+ // Stream a chunk every 2s for 12s — total elapsed > idleMs but
80
+ // never idle for >5s in a row.
81
+ for (let i = 0; i < 6; i++) {
82
+ vi.advanceTimersByTime(2_000);
83
+ broadcast(run, delta("x"));
84
+ }
85
+ expect(run.status).toBe("running");
86
+ // Now go quiet and confirm it fires.
87
+ vi.advanceTimersByTime(5_000 + 10);
88
+ expect(run.status).toBe("error");
89
+ } finally {
90
+ if (prev === undefined) delete process.env.JARELA_RUN_IDLE_MS;
91
+ else process.env.JARELA_RUN_IDLE_MS = prev;
92
+ }
93
+ });
94
+ });