stagent 0.9.3 → 0.9.6

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 (50) hide show
  1. package/dist/cli.js +36 -1
  2. package/docs/superpowers/specs/2026-04-06-workflow-intelligence-stack-design.md +388 -0
  3. package/package.json +1 -1
  4. package/src/app/api/license/route.ts +3 -2
  5. package/src/app/api/workflows/[id]/debug/route.ts +18 -0
  6. package/src/app/api/workflows/[id]/execute/route.ts +39 -8
  7. package/src/app/api/workflows/optimize/route.ts +30 -0
  8. package/src/app/layout.tsx +4 -2
  9. package/src/components/chat/chat-message-markdown.tsx +78 -3
  10. package/src/components/chat/chat-message.tsx +12 -4
  11. package/src/components/settings/cloud-account-section.tsx +14 -12
  12. package/src/components/workflows/error-timeline.tsx +83 -0
  13. package/src/components/workflows/step-live-metrics.tsx +182 -0
  14. package/src/components/workflows/step-progress-bar.tsx +77 -0
  15. package/src/components/workflows/workflow-debug-panel.tsx +192 -0
  16. package/src/components/workflows/workflow-optimizer-panel.tsx +227 -0
  17. package/src/lib/agents/claude-agent.ts +4 -4
  18. package/src/lib/agents/runtime/anthropic-direct.ts +3 -3
  19. package/src/lib/agents/runtime/catalog.ts +30 -1
  20. package/src/lib/agents/runtime/openai-direct.ts +3 -3
  21. package/src/lib/billing/products.ts +6 -6
  22. package/src/lib/book/chapter-mapping.ts +6 -0
  23. package/src/lib/book/content.ts +10 -0
  24. package/src/lib/book/reading-paths.ts +1 -1
  25. package/src/lib/chat/__tests__/engine-stream-helpers.test.ts +57 -0
  26. package/src/lib/chat/engine.ts +68 -7
  27. package/src/lib/chat/stagent-tools.ts +2 -0
  28. package/src/lib/chat/tools/runtime-tools.ts +28 -0
  29. package/src/lib/chat/tools/schedule-tools.ts +44 -1
  30. package/src/lib/chat/tools/settings-tools.ts +40 -10
  31. package/src/lib/chat/tools/workflow-tools.ts +93 -4
  32. package/src/lib/chat/types.ts +21 -0
  33. package/src/lib/data/clear.ts +3 -0
  34. package/src/lib/db/bootstrap.ts +38 -0
  35. package/src/lib/db/migrations/0022_workflow_intelligence_phase1.sql +5 -0
  36. package/src/lib/db/migrations/0023_add_execution_stats.sql +15 -0
  37. package/src/lib/db/schema.ts +41 -1
  38. package/src/lib/license/__tests__/manager.test.ts +64 -0
  39. package/src/lib/license/manager.ts +80 -25
  40. package/src/lib/schedules/__tests__/interval-parser.test.ts +87 -0
  41. package/src/lib/schedules/__tests__/prompt-analyzer.test.ts +51 -0
  42. package/src/lib/schedules/interval-parser.ts +187 -0
  43. package/src/lib/schedules/prompt-analyzer.ts +87 -0
  44. package/src/lib/schedules/scheduler.ts +179 -9
  45. package/src/lib/workflows/cost-estimator.ts +141 -0
  46. package/src/lib/workflows/engine.ts +245 -45
  47. package/src/lib/workflows/error-analysis.ts +249 -0
  48. package/src/lib/workflows/execution-stats.ts +252 -0
  49. package/src/lib/workflows/optimizer.ts +193 -0
  50. package/src/lib/workflows/types.ts +6 -0
@@ -1,14 +1,62 @@
1
1
  "use client";
2
2
 
3
- import { memo, useState, useCallback } from "react";
3
+ import { memo, useMemo, useState, useCallback } from "react";
4
4
  import ReactMarkdown from "react-markdown";
5
5
  import remarkGfm from "remark-gfm";
6
- import { Check, Copy } from "lucide-react";
6
+ import { Check, Copy, ImageIcon } from "lucide-react";
7
7
  import { Button } from "@/components/ui/button";
8
+ import { ScreenshotLightbox } from "@/components/shared/screenshot-lightbox";
9
+ import type { ScreenshotAttachment } from "@/lib/chat/types";
8
10
  import type { Components } from "react-markdown";
9
11
 
10
12
  interface ChatMessageMarkdownProps {
11
13
  content: string;
14
+ attachments?: ScreenshotAttachment[];
15
+ }
16
+
17
+ function InlineScreenshot({ attachment }: { attachment: ScreenshotAttachment }) {
18
+ const [open, setOpen] = useState(false);
19
+ return (
20
+ <>
21
+ <button
22
+ type="button"
23
+ className="relative rounded-lg overflow-hidden border border-border hover:border-primary transition-colors cursor-pointer group my-3 block w-full"
24
+ onClick={() => setOpen(true)}
25
+ >
26
+ <img
27
+ src={attachment.thumbnailUrl}
28
+ alt={`Screenshot ${attachment.width}×${attachment.height}`}
29
+ className="object-contain w-full"
30
+ style={{ maxHeight: 400 }}
31
+ loading="lazy"
32
+ onError={(e) => {
33
+ const img = e.currentTarget;
34
+ if (!img.src.includes(attachment.originalUrl)) {
35
+ img.src = attachment.originalUrl;
36
+ } else {
37
+ img.style.display = "none";
38
+ img.parentElement?.classList.add("bg-muted");
39
+ }
40
+ }}
41
+ />
42
+ <div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
43
+ <ImageIcon className="h-5 w-5 text-white opacity-0 group-hover:opacity-70 transition-opacity" />
44
+ </div>
45
+ <span className="absolute bottom-1 right-1 text-[9px] bg-black/50 text-white px-1.5 py-0.5 rounded">
46
+ {attachment.width}×{attachment.height}
47
+ </span>
48
+ </button>
49
+ {open && (
50
+ <ScreenshotLightbox
51
+ open={open}
52
+ onClose={() => setOpen(false)}
53
+ imageUrl={attachment.originalUrl}
54
+ width={attachment.width}
55
+ height={attachment.height}
56
+ />
57
+ )}
58
+ </>
59
+ );
12
60
  }
13
61
 
14
62
  function CodeBlock({
@@ -119,10 +167,37 @@ const components: Components = {
119
167
 
120
168
  export const ChatMessageMarkdown = memo(function ChatMessageMarkdown({
121
169
  content,
170
+ attachments,
122
171
  }: ChatMessageMarkdownProps) {
172
+ // Resolve markdown img refs back to their full attachment record so the
173
+ // inline thumbnail can open the lightbox at the original resolution.
174
+ const attachmentBySrc = useMemo(() => {
175
+ const map = new Map<string, ScreenshotAttachment>();
176
+ for (const att of attachments ?? []) {
177
+ map.set(att.thumbnailUrl, att);
178
+ map.set(att.originalUrl, att);
179
+ }
180
+ return map;
181
+ }, [attachments]);
182
+
183
+ const componentsWithImg: Components = useMemo(
184
+ () => ({
185
+ ...components,
186
+ img: ({ src, alt }) => {
187
+ const key = typeof src === "string" ? src : "";
188
+ const att = attachmentBySrc.get(key);
189
+ if (att) {
190
+ return <InlineScreenshot attachment={att} />;
191
+ }
192
+ return <img src={key} alt={alt ?? ""} className="max-w-full rounded my-2" />;
193
+ },
194
+ }),
195
+ [attachmentBySrc]
196
+ );
197
+
123
198
  return (
124
199
  <div className="prose-chat">
125
- <ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
200
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={componentsWithImg}>
126
201
  {content}
127
202
  </ReactMarkdown>
128
203
  </div>
@@ -117,7 +117,10 @@ export function ChatMessage({ message, isStreaming, conversationId, onStatusChan
117
117
  ) : (
118
118
  <div className="text-sm">
119
119
  {message.content ? (
120
- <ChatMessageMarkdown content={message.content} />
120
+ <ChatMessageMarkdown
121
+ content={message.content}
122
+ attachments={attachments}
123
+ />
121
124
  ) : isStreaming ? (
122
125
  <span className="text-muted-foreground text-xs animate-pulse">
123
126
  {(() => {
@@ -128,9 +131,14 @@ export function ChatMessage({ message, isStreaming, conversationId, onStatusChan
128
131
  })()}
129
132
  </span>
130
133
  ) : null}
131
- {attachments.length > 0 && (
132
- <ScreenshotGallery attachments={attachments} />
133
- )}
134
+ {/* Legacy fallback: messages saved before inline screenshot rendering
135
+ stored attachments in metadata but never embedded markdown image
136
+ refs in `content`. Detect that case and show the trailing gallery
137
+ so historical conversations don't lose their visuals. */}
138
+ {attachments.length > 0 &&
139
+ !attachments.some((att) =>
140
+ message.content?.includes(`](${att.thumbnailUrl})`)
141
+ ) && <ScreenshotGallery attachments={attachments} />}
134
142
  {isStreaming && message.content && (
135
143
  <span className="inline-block w-0.5 h-4 bg-foreground animate-pulse ml-0.5 align-text-bottom" />
136
144
  )}
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useState } from "react";
3
+ import { useState, useEffect } from "react";
4
4
  import { User, LogOut, Mail, CheckCircle2 } from "lucide-react";
5
5
  import { toast } from "sonner";
6
6
  import {
@@ -47,21 +47,23 @@ export function CloudAccountSection() {
47
47
  toast.success("Signed out");
48
48
  }
49
49
 
50
- // Handle auth callback URL params
51
- if (typeof window !== "undefined") {
50
+ // Handle auth callback URL params — must be in useEffect, not during render
51
+ useEffect(() => {
52
52
  const params = new URLSearchParams(window.location.search);
53
- if (params.get("auth") === "success" && !loading) {
53
+ const authParam = params.get("auth");
54
+ if (!authParam || loading) return;
55
+
56
+ if (authParam === "success") {
54
57
  toast.success("Signed in successfully");
55
- const url = new URL(window.location.href);
56
- url.searchParams.delete("auth");
57
- window.history.replaceState({}, "", url.toString());
58
- } else if (params.get("auth") === "error") {
58
+ } else if (authParam === "error") {
59
59
  toast.error("Sign-in failed — try again");
60
- const url = new URL(window.location.href);
61
- url.searchParams.delete("auth");
62
- window.history.replaceState({}, "", url.toString());
63
60
  }
64
- }
61
+
62
+ // Clean up URL
63
+ const url = new URL(window.location.href);
64
+ url.searchParams.delete("auth");
65
+ window.history.replaceState({}, "", url.toString());
66
+ }, [loading]);
65
67
 
66
68
  if (loading) {
67
69
  return (
@@ -0,0 +1,83 @@
1
+ "use client";
2
+
3
+ interface TimelineEvent {
4
+ timestamp: string;
5
+ event: string;
6
+ severity: "success" | "warning" | "error";
7
+ details: string;
8
+ }
9
+
10
+ interface ErrorTimelineProps {
11
+ events: TimelineEvent[];
12
+ }
13
+
14
+ const severityColor: Record<TimelineEvent["severity"], string> = {
15
+ success: "bg-green-500",
16
+ warning: "bg-amber-500",
17
+ error: "bg-red-500",
18
+ };
19
+
20
+ function formatRelativeTime(firstMs: number, currentMs: number): string {
21
+ const diffMs = currentMs - firstMs;
22
+ if (diffMs < 1000) return "+0s";
23
+ const totalSeconds = Math.floor(diffMs / 1000);
24
+ if (totalSeconds < 60) return `+${totalSeconds}s`;
25
+ const minutes = Math.floor(totalSeconds / 60);
26
+ const seconds = totalSeconds % 60;
27
+ return `+${minutes}m ${seconds}s`;
28
+ }
29
+
30
+ function formatEventLabel(event: string): string {
31
+ return event
32
+ .replace(/_/g, " ")
33
+ .replace(/\b\w/g, (c) => c.toUpperCase());
34
+ }
35
+
36
+ export function ErrorTimeline({ events }: ErrorTimelineProps) {
37
+ if (events.length === 0) {
38
+ return (
39
+ <p className="text-sm text-muted-foreground py-4">
40
+ No timeline events found.
41
+ </p>
42
+ );
43
+ }
44
+
45
+ const firstTimestamp = new Date(events[0].timestamp).getTime();
46
+
47
+ return (
48
+ <div className="relative pl-6">
49
+ {/* Vertical line */}
50
+ <div className="absolute left-[5px] top-1 bottom-1 w-0.5 bg-border" />
51
+
52
+ <div className="space-y-4">
53
+ {events.map((event, index) => {
54
+ const currentMs = new Date(event.timestamp).getTime();
55
+ const relTime = formatRelativeTime(firstTimestamp, currentMs);
56
+
57
+ return (
58
+ <div key={index} className="relative flex items-start gap-3">
59
+ {/* Dot */}
60
+ <div
61
+ className={`absolute -left-6 top-1 h-3 w-3 rounded-full border-2 border-background ${severityColor[event.severity]}`}
62
+ />
63
+
64
+ <div className="min-w-0 flex-1">
65
+ <div className="flex items-center gap-2">
66
+ <span className="text-sm font-medium text-foreground">
67
+ {formatEventLabel(event.event)}
68
+ </span>
69
+ <span className="text-xs text-muted-foreground font-mono">
70
+ {relTime}
71
+ </span>
72
+ </div>
73
+ <p className="text-xs text-muted-foreground mt-0.5 break-words">
74
+ {event.details}
75
+ </p>
76
+ </div>
77
+ </div>
78
+ );
79
+ })}
80
+ </div>
81
+ </div>
82
+ );
83
+ }
@@ -0,0 +1,182 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { Wrench, Clock, Coins, Hash } from "lucide-react";
5
+
6
+ interface StepLiveMetricsProps {
7
+ taskId: string;
8
+ budgetCapUsd: number;
9
+ }
10
+
11
+ interface MetricsState {
12
+ tokens: number;
13
+ costUsd: number;
14
+ currentTool: string | null;
15
+ turnNumber: number;
16
+ startedAt: number;
17
+ elapsed: string;
18
+ }
19
+
20
+ function formatElapsed(ms: number): string {
21
+ const totalSeconds = Math.floor(ms / 1000);
22
+ const minutes = Math.floor(totalSeconds / 60);
23
+ const seconds = totalSeconds % 60;
24
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
25
+ return `${seconds}s`;
26
+ }
27
+
28
+ export function StepLiveMetrics({ taskId, budgetCapUsd }: StepLiveMetricsProps) {
29
+ const [metrics, setMetrics] = useState<MetricsState>({
30
+ tokens: 0,
31
+ costUsd: 0,
32
+ currentTool: null,
33
+ turnNumber: 0,
34
+ startedAt: Date.now(),
35
+ elapsed: "0s",
36
+ });
37
+ const [connected, setConnected] = useState(false);
38
+ const eventSourceRef = useRef<EventSource | null>(null);
39
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
40
+
41
+ // Elapsed time ticker
42
+ useEffect(() => {
43
+ timerRef.current = setInterval(() => {
44
+ setMetrics((prev) => ({
45
+ ...prev,
46
+ elapsed: formatElapsed(Date.now() - prev.startedAt),
47
+ }));
48
+ }, 1000);
49
+ return () => {
50
+ if (timerRef.current) clearInterval(timerRef.current);
51
+ };
52
+ }, []);
53
+
54
+ // SSE subscription
55
+ useEffect(() => {
56
+ const params = new URLSearchParams({ taskId });
57
+ const es = new EventSource(`/api/logs/stream?${params}`);
58
+ eventSourceRef.current = es;
59
+
60
+ es.onopen = () => setConnected(true);
61
+ es.onerror = () => setConnected(false);
62
+
63
+ es.onmessage = (event) => {
64
+ try {
65
+ const data = JSON.parse(event.data);
66
+ const eventType: string = data.event ?? "";
67
+ const payload = data.payload ? JSON.parse(data.payload) : {};
68
+
69
+ if (eventType === "tool_start" || eventType === "tool_use") {
70
+ setMetrics((prev) => ({
71
+ ...prev,
72
+ currentTool: payload.toolName ?? payload.tool ?? prev.currentTool,
73
+ turnNumber: prev.turnNumber + 1,
74
+ }));
75
+ }
76
+
77
+ if (eventType === "content_block_delta" || eventType === "token_usage") {
78
+ const tokenDelta = payload.tokens ?? payload.inputTokens ?? 0;
79
+ const costDelta = payload.costUsd ?? 0;
80
+ setMetrics((prev) => ({
81
+ ...prev,
82
+ tokens: prev.tokens + tokenDelta,
83
+ costUsd: prev.costUsd + costDelta,
84
+ }));
85
+ }
86
+
87
+ if (eventType === "completed" || eventType === "step_completed") {
88
+ setMetrics((prev) => ({
89
+ ...prev,
90
+ currentTool: null,
91
+ }));
92
+ if (timerRef.current) clearInterval(timerRef.current);
93
+ }
94
+ } catch {
95
+ // Ignore malformed events
96
+ }
97
+ };
98
+
99
+ return () => {
100
+ es.close();
101
+ eventSourceRef.current = null;
102
+ };
103
+ }, [taskId]);
104
+
105
+ const costPercent = budgetCapUsd > 0 ? Math.min((metrics.costUsd / budgetCapUsd) * 100, 100) : 0;
106
+
107
+ return (
108
+ <div className="grid grid-cols-2 gap-3">
109
+ {/* Tokens tile */}
110
+ <div className="rounded-lg border bg-white p-3 shadow-sm">
111
+ <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
112
+ <Hash className="h-3.5 w-3.5" />
113
+ Tokens
114
+ </div>
115
+ <div className="mt-1 font-mono text-lg font-semibold">
116
+ {metrics.tokens.toLocaleString()}
117
+ </div>
118
+ <div className="text-xs text-muted-foreground">tokens</div>
119
+ </div>
120
+
121
+ {/* Cost tile */}
122
+ <div className="rounded-lg border bg-white p-3 shadow-sm">
123
+ <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
124
+ <Coins className="h-3.5 w-3.5" />
125
+ Cost
126
+ </div>
127
+ <div className="mt-1 font-mono text-lg font-semibold">
128
+ ${metrics.costUsd.toFixed(2)}
129
+ </div>
130
+ <div className="mt-1">
131
+ <div className="h-1.5 w-full rounded-full bg-[oklch(0.92_0_0)]">
132
+ <div
133
+ className="h-1.5 rounded-full bg-[oklch(0.6_0.15_250)] transition-all duration-300"
134
+ style={{ width: `${costPercent}%` }}
135
+ />
136
+ </div>
137
+ <div className="mt-0.5 text-xs text-muted-foreground">
138
+ of ${budgetCapUsd.toFixed(2)} cap
139
+ </div>
140
+ </div>
141
+ </div>
142
+
143
+ {/* Current Tool tile */}
144
+ <div className="rounded-lg border bg-white p-3 shadow-sm">
145
+ <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
146
+ <Wrench className="h-3.5 w-3.5" />
147
+ Current Tool
148
+ </div>
149
+ <div className="mt-1 truncate font-mono text-lg font-semibold">
150
+ {metrics.currentTool ?? "\u2014"}
151
+ </div>
152
+ <div className="text-xs text-muted-foreground">
153
+ turn {metrics.turnNumber}
154
+ </div>
155
+ </div>
156
+
157
+ {/* Elapsed tile */}
158
+ <div className="rounded-lg border bg-white p-3 shadow-sm">
159
+ <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
160
+ <Clock className="h-3.5 w-3.5" />
161
+ Elapsed
162
+ </div>
163
+ <div className="mt-1 font-mono text-lg font-semibold">
164
+ {metrics.elapsed}
165
+ </div>
166
+ <div className="flex items-center gap-1 text-xs text-muted-foreground">
167
+ {connected ? (
168
+ <>
169
+ <span className="inline-block h-1.5 w-1.5 rounded-full bg-[oklch(0.65_0.15_145)]" />
170
+ live
171
+ </>
172
+ ) : (
173
+ <>
174
+ <span className="inline-block h-1.5 w-1.5 rounded-full bg-[oklch(0.8_0_0)]" />
175
+ disconnected
176
+ </>
177
+ )}
178
+ </div>
179
+ </div>
180
+ </div>
181
+ );
182
+ }
@@ -0,0 +1,77 @@
1
+ "use client";
2
+
3
+ import { cn } from "@/lib/utils";
4
+
5
+ type StepStatus =
6
+ | "pending"
7
+ | "running"
8
+ | "completed"
9
+ | "failed"
10
+ | "waiting_approval"
11
+ | "waiting_dependencies";
12
+
13
+ interface StepProgressBarProps {
14
+ steps: Array<{
15
+ stepId: string;
16
+ name: string;
17
+ status: StepStatus;
18
+ }>;
19
+ }
20
+
21
+ const statusClasses: Record<StepStatus, string> = {
22
+ completed: "bg-[oklch(0.65_0.15_145)] text-white",
23
+ running: "bg-[oklch(0.6_0.15_250)] text-white animate-pulse",
24
+ failed: "bg-[oklch(0.6_0.2_25)] text-white",
25
+ pending: "bg-[oklch(0.8_0_0)] text-[oklch(0.4_0_0)]",
26
+ waiting_approval: "bg-[oklch(0.7_0.15_80)] text-white",
27
+ waiting_dependencies: "bg-[oklch(0.7_0.15_80)] text-white",
28
+ };
29
+
30
+ const lineColor = (status: StepStatus): string => {
31
+ if (status === "completed") return "bg-[oklch(0.65_0.15_145)]";
32
+ return "bg-[oklch(0.85_0_0)]";
33
+ };
34
+
35
+ export function StepProgressBar({ steps }: StepProgressBarProps) {
36
+ if (steps.length === 0) return null;
37
+
38
+ return (
39
+ <div className="w-full overflow-x-auto">
40
+ <div className="flex items-start min-w-max px-2 py-3">
41
+ {steps.map((step, index) => (
42
+ <div key={step.stepId} className="flex items-start">
43
+ {/* Step circle + label */}
44
+ <div className="flex flex-col items-center gap-1.5">
45
+ <div
46
+ className={cn(
47
+ "flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold",
48
+ statusClasses[step.status]
49
+ )}
50
+ >
51
+ {index + 1}
52
+ </div>
53
+ <span
54
+ className="max-w-[72px] truncate text-center text-xs text-muted-foreground"
55
+ title={step.name}
56
+ >
57
+ {step.name}
58
+ </span>
59
+ </div>
60
+
61
+ {/* Connecting line */}
62
+ {index < steps.length - 1 && (
63
+ <div className="flex items-center pt-4">
64
+ <div
65
+ className={cn(
66
+ "mx-1 h-0.5 w-10",
67
+ lineColor(step.status)
68
+ )}
69
+ />
70
+ </div>
71
+ )}
72
+ </div>
73
+ ))}
74
+ </div>
75
+ </div>
76
+ );
77
+ }