stagent 0.1.6 → 0.1.9
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/dist/cli.js +16 -24
- package/package.json +1 -1
- package/src/app/api/documents/route.ts +21 -2
- package/src/app/api/tasks/route.ts +16 -3
- package/src/app/api/uploads/route.ts +17 -3
- package/src/app/globals.css +156 -120
- package/src/app/inbox/page.tsx +1 -1
- package/src/app/layout.tsx +6 -6
- package/src/app/page.tsx +1 -1
- package/src/app/profiles/[id]/edit/page.tsx +1 -1
- package/src/app/profiles/[id]/page.tsx +1 -1
- package/src/app/profiles/new/page.tsx +1 -1
- package/src/app/profiles/page.tsx +1 -1
- package/src/app/projects/page.tsx +1 -1
- package/src/app/settings/loading.tsx +1 -1
- package/src/app/settings/page.tsx +1 -1
- package/src/app/tasks/new/page.tsx +10 -2
- package/src/app/workflows/[id]/edit/page.tsx +1 -1
- package/src/app/workflows/new/page.tsx +1 -1
- package/src/components/costs/cost-dashboard.tsx +1 -1
- package/src/components/shared/theme-toggle.tsx +1 -1
- package/src/components/tasks/__tests__/kanban-board-persistence.test.tsx +124 -0
- package/src/components/tasks/__tests__/task-create-panel.test.tsx +58 -0
- package/src/components/tasks/ai-assist-panel.tsx +50 -12
- package/src/components/tasks/kanban-board.tsx +201 -5
- package/src/components/tasks/kanban-column.tsx +156 -5
- package/src/components/tasks/task-card.tsx +186 -44
- package/src/components/tasks/task-create-panel.tsx +3 -2
- package/src/components/tasks/task-detail-view.tsx +58 -1
- package/src/components/tasks/task-edit-dialog.tsx +277 -0
- package/src/hooks/__tests__/use-persisted-state.test.ts +57 -0
- package/src/hooks/use-persisted-state.ts +40 -0
- package/src/lib/agents/claude-agent.ts +17 -7
- package/src/lib/agents/runtime/claude-sdk.ts +20 -6
- package/src/lib/agents/runtime/claude.ts +23 -5
- package/src/lib/agents/runtime/openai-codex.ts +14 -1
- package/src/lib/db/bootstrap.ts +17 -32
- package/src/lib/documents/cleanup.ts +3 -2
- package/src/lib/notifications/permissions.ts +4 -2
- package/src/lib/workflows/engine.ts +2 -2
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Dialog,
|
|
6
|
+
DialogContent,
|
|
7
|
+
DialogDescription,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
} from "@/components/ui/dialog";
|
|
11
|
+
import { Button } from "@/components/ui/button";
|
|
12
|
+
import { Input } from "@/components/ui/input";
|
|
13
|
+
import { Textarea } from "@/components/ui/textarea";
|
|
14
|
+
import { Label } from "@/components/ui/label";
|
|
15
|
+
import {
|
|
16
|
+
Select,
|
|
17
|
+
SelectContent,
|
|
18
|
+
SelectItem,
|
|
19
|
+
SelectTrigger,
|
|
20
|
+
SelectValue,
|
|
21
|
+
} from "@/components/ui/select";
|
|
22
|
+
import { Bot, FileText, AlignLeft } from "lucide-react";
|
|
23
|
+
import { toast } from "sonner";
|
|
24
|
+
import {
|
|
25
|
+
type AgentRuntimeId,
|
|
26
|
+
DEFAULT_AGENT_RUNTIME,
|
|
27
|
+
listRuntimeCatalog,
|
|
28
|
+
} from "@/lib/agents/runtime/catalog";
|
|
29
|
+
import {
|
|
30
|
+
getSupportedRuntimes,
|
|
31
|
+
profileSupportsRuntime,
|
|
32
|
+
} from "@/lib/agents/profiles/compatibility";
|
|
33
|
+
import type { AgentProfile } from "@/lib/agents/profiles/types";
|
|
34
|
+
import type { TaskItem } from "./task-card";
|
|
35
|
+
|
|
36
|
+
type ProfileOption = Pick<
|
|
37
|
+
AgentProfile,
|
|
38
|
+
"id" | "name" | "description" | "supportedRuntimes"
|
|
39
|
+
>;
|
|
40
|
+
|
|
41
|
+
const PRIORITY_COLORS: Record<string, string> = {
|
|
42
|
+
"0": "bg-[var(--priority-critical)]",
|
|
43
|
+
"1": "bg-[var(--priority-high)]",
|
|
44
|
+
"2": "bg-[var(--priority-medium)]",
|
|
45
|
+
"3": "bg-[var(--priority-low)]",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
interface TaskEditDialogProps {
|
|
49
|
+
task: TaskItem | null;
|
|
50
|
+
open: boolean;
|
|
51
|
+
onOpenChange: (open: boolean) => void;
|
|
52
|
+
onUpdated: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function TaskEditDialog({
|
|
56
|
+
task,
|
|
57
|
+
open,
|
|
58
|
+
onOpenChange,
|
|
59
|
+
onUpdated,
|
|
60
|
+
}: TaskEditDialogProps) {
|
|
61
|
+
const runtimeOptions = listRuntimeCatalog();
|
|
62
|
+
const runtimeLabelMap = new Map(
|
|
63
|
+
runtimeOptions.map((runtime) => [runtime.id, runtime.label])
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const [title, setTitle] = useState("");
|
|
67
|
+
const [description, setDescription] = useState("");
|
|
68
|
+
const [priority, setPriority] = useState("2");
|
|
69
|
+
const [assignedAgent, setAssignedAgent] = useState("");
|
|
70
|
+
const [agentProfile, setAgentProfile] = useState("");
|
|
71
|
+
const [profiles, setProfiles] = useState<ProfileOption[]>([]);
|
|
72
|
+
const [loading, setLoading] = useState(false);
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
fetch("/api/profiles")
|
|
76
|
+
.then((r) => r.json())
|
|
77
|
+
.then((data: ProfileOption[]) => setProfiles(data))
|
|
78
|
+
.catch(() => {});
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (task) {
|
|
83
|
+
setTitle(task.title);
|
|
84
|
+
setDescription(task.description ?? "");
|
|
85
|
+
setPriority(String(task.priority));
|
|
86
|
+
setAssignedAgent(task.assignedAgent ?? "");
|
|
87
|
+
setAgentProfile(task.agentProfile ?? "");
|
|
88
|
+
}
|
|
89
|
+
}, [task]);
|
|
90
|
+
|
|
91
|
+
const selectedRuntimeId = (assignedAgent ||
|
|
92
|
+
DEFAULT_AGENT_RUNTIME) as AgentRuntimeId;
|
|
93
|
+
const selectedProfile = profiles.find((p) => p.id === agentProfile);
|
|
94
|
+
const profileCompatibilityError =
|
|
95
|
+
selectedProfile && !profileSupportsRuntime(selectedProfile, selectedRuntimeId)
|
|
96
|
+
? `${selectedProfile.name} does not support ${
|
|
97
|
+
runtimeLabelMap.get(selectedRuntimeId) ?? selectedRuntimeId
|
|
98
|
+
}`
|
|
99
|
+
: null;
|
|
100
|
+
|
|
101
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
if (!task || !title.trim()) return;
|
|
104
|
+
if (profileCompatibilityError) return;
|
|
105
|
+
setLoading(true);
|
|
106
|
+
try {
|
|
107
|
+
const res = await fetch(`/api/tasks/${task.id}`, {
|
|
108
|
+
method: "PATCH",
|
|
109
|
+
headers: { "Content-Type": "application/json" },
|
|
110
|
+
body: JSON.stringify({
|
|
111
|
+
title: title.trim(),
|
|
112
|
+
description: description.trim() || undefined,
|
|
113
|
+
priority: parseInt(priority, 10),
|
|
114
|
+
assignedAgent: assignedAgent || undefined,
|
|
115
|
+
agentProfile: agentProfile || undefined,
|
|
116
|
+
}),
|
|
117
|
+
});
|
|
118
|
+
if (res.ok) {
|
|
119
|
+
toast.success("Task updated");
|
|
120
|
+
onOpenChange(false);
|
|
121
|
+
onUpdated();
|
|
122
|
+
} else {
|
|
123
|
+
const data = await res.json().catch(() => null);
|
|
124
|
+
toast.error(data?.error ?? "Failed to update task");
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
toast.error("Network error — could not reach server");
|
|
128
|
+
} finally {
|
|
129
|
+
setLoading(false);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
135
|
+
<DialogContent>
|
|
136
|
+
<DialogHeader>
|
|
137
|
+
<DialogTitle>Edit Task</DialogTitle>
|
|
138
|
+
<DialogDescription>
|
|
139
|
+
Update task details. Only planned and queued tasks can be edited.
|
|
140
|
+
</DialogDescription>
|
|
141
|
+
</DialogHeader>
|
|
142
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
143
|
+
<div className="space-y-2">
|
|
144
|
+
<Label htmlFor="edit-task-title" className="flex items-center gap-1.5">
|
|
145
|
+
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
|
|
146
|
+
Title
|
|
147
|
+
</Label>
|
|
148
|
+
<Input
|
|
149
|
+
id="edit-task-title"
|
|
150
|
+
value={title}
|
|
151
|
+
onChange={(e) => setTitle(e.target.value)}
|
|
152
|
+
required
|
|
153
|
+
maxLength={200}
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
<div className="space-y-2">
|
|
157
|
+
<Label htmlFor="edit-task-desc" className="flex items-center gap-1.5">
|
|
158
|
+
<AlignLeft className="h-3.5 w-3.5 text-muted-foreground" />
|
|
159
|
+
Description
|
|
160
|
+
</Label>
|
|
161
|
+
<Textarea
|
|
162
|
+
id="edit-task-desc"
|
|
163
|
+
value={description}
|
|
164
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
165
|
+
rows={3}
|
|
166
|
+
placeholder="Detailed instructions for the agent"
|
|
167
|
+
/>
|
|
168
|
+
</div>
|
|
169
|
+
<div className="space-y-2">
|
|
170
|
+
<Label>Priority</Label>
|
|
171
|
+
<Select value={priority} onValueChange={setPriority}>
|
|
172
|
+
<SelectTrigger>
|
|
173
|
+
<SelectValue />
|
|
174
|
+
</SelectTrigger>
|
|
175
|
+
<SelectContent>
|
|
176
|
+
{[
|
|
177
|
+
{ value: "0", label: "P0 - Critical" },
|
|
178
|
+
{ value: "1", label: "P1 - High" },
|
|
179
|
+
{ value: "2", label: "P2 - Medium" },
|
|
180
|
+
{ value: "3", label: "P3 - Low" },
|
|
181
|
+
].map((p) => (
|
|
182
|
+
<SelectItem key={p.value} value={p.value}>
|
|
183
|
+
<span className="flex items-center gap-1.5">
|
|
184
|
+
<span className={`h-2 w-2 rounded-full ${PRIORITY_COLORS[p.value]} inline-block`} />
|
|
185
|
+
{p.label}
|
|
186
|
+
</span>
|
|
187
|
+
</SelectItem>
|
|
188
|
+
))}
|
|
189
|
+
</SelectContent>
|
|
190
|
+
</Select>
|
|
191
|
+
</div>
|
|
192
|
+
<div className="space-y-2">
|
|
193
|
+
<Label className="flex items-center gap-1.5">
|
|
194
|
+
<Bot className="h-3.5 w-3.5 text-muted-foreground" />
|
|
195
|
+
Runtime
|
|
196
|
+
</Label>
|
|
197
|
+
<Select
|
|
198
|
+
value={assignedAgent || "default"}
|
|
199
|
+
onValueChange={(value) =>
|
|
200
|
+
setAssignedAgent(value === "default" ? "" : value)
|
|
201
|
+
}
|
|
202
|
+
>
|
|
203
|
+
<SelectTrigger>
|
|
204
|
+
<SelectValue placeholder="Default runtime" />
|
|
205
|
+
</SelectTrigger>
|
|
206
|
+
<SelectContent>
|
|
207
|
+
<SelectItem value="default">Default runtime</SelectItem>
|
|
208
|
+
{runtimeOptions.map((runtime) => (
|
|
209
|
+
<SelectItem key={runtime.id} value={runtime.id}>
|
|
210
|
+
{runtime.label}
|
|
211
|
+
</SelectItem>
|
|
212
|
+
))}
|
|
213
|
+
</SelectContent>
|
|
214
|
+
</Select>
|
|
215
|
+
</div>
|
|
216
|
+
{profiles.length > 0 && (
|
|
217
|
+
<div className="space-y-2">
|
|
218
|
+
<Label className="flex items-center gap-1.5">
|
|
219
|
+
<Bot className="h-3.5 w-3.5 text-muted-foreground" />
|
|
220
|
+
Agent Profile
|
|
221
|
+
</Label>
|
|
222
|
+
<Select
|
|
223
|
+
value={agentProfile || "auto"}
|
|
224
|
+
onValueChange={(value) =>
|
|
225
|
+
setAgentProfile(value === "auto" ? "" : value)
|
|
226
|
+
}
|
|
227
|
+
>
|
|
228
|
+
<SelectTrigger>
|
|
229
|
+
<SelectValue placeholder="Auto-detect" />
|
|
230
|
+
</SelectTrigger>
|
|
231
|
+
<SelectContent>
|
|
232
|
+
<SelectItem value="auto">Auto-detect</SelectItem>
|
|
233
|
+
{profiles.map((p) => (
|
|
234
|
+
<SelectItem
|
|
235
|
+
key={p.id}
|
|
236
|
+
value={p.id}
|
|
237
|
+
disabled={!profileSupportsRuntime(p, selectedRuntimeId)}
|
|
238
|
+
>
|
|
239
|
+
<span className="flex items-center gap-1.5">
|
|
240
|
+
<Bot className="h-3 w-3" />
|
|
241
|
+
{p.name}
|
|
242
|
+
</span>
|
|
243
|
+
</SelectItem>
|
|
244
|
+
))}
|
|
245
|
+
</SelectContent>
|
|
246
|
+
</Select>
|
|
247
|
+
{selectedProfile && (
|
|
248
|
+
<p
|
|
249
|
+
className={`text-xs ${
|
|
250
|
+
profileCompatibilityError
|
|
251
|
+
? "text-destructive"
|
|
252
|
+
: "text-muted-foreground"
|
|
253
|
+
}`}
|
|
254
|
+
>
|
|
255
|
+
{profileCompatibilityError ??
|
|
256
|
+
`Supports ${getSupportedRuntimes(selectedProfile)
|
|
257
|
+
.map(
|
|
258
|
+
(runtimeId) =>
|
|
259
|
+
runtimeLabelMap.get(runtimeId) ?? runtimeId
|
|
260
|
+
)
|
|
261
|
+
.join(", ")}`}
|
|
262
|
+
</p>
|
|
263
|
+
)}
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
<Button
|
|
267
|
+
type="submit"
|
|
268
|
+
disabled={loading || !title.trim() || !!profileCompatibilityError}
|
|
269
|
+
className="w-full"
|
|
270
|
+
>
|
|
271
|
+
{loading ? "Saving..." : "Save Changes"}
|
|
272
|
+
</Button>
|
|
273
|
+
</form>
|
|
274
|
+
</DialogContent>
|
|
275
|
+
</Dialog>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { renderHook, act } from "@testing-library/react";
|
|
2
|
+
import { usePersistedState } from "@/hooks/use-persisted-state";
|
|
3
|
+
|
|
4
|
+
describe("usePersistedState", () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
localStorage.clear();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("returns default value when localStorage is empty", () => {
|
|
10
|
+
const { result } = renderHook(() => usePersistedState("test-key", "default"));
|
|
11
|
+
expect(result.current[0]).toBe("default");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("reads stored value from localStorage on mount", () => {
|
|
15
|
+
localStorage.setItem("test-key", "stored-value");
|
|
16
|
+
const { result } = renderHook(() => usePersistedState("test-key", "default"));
|
|
17
|
+
// After effect runs, value should be the stored one
|
|
18
|
+
expect(result.current[0]).toBe("stored-value");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("writes to localStorage when setter is called", () => {
|
|
22
|
+
const { result } = renderHook(() => usePersistedState("test-key", "default"));
|
|
23
|
+
|
|
24
|
+
act(() => {
|
|
25
|
+
result.current[1]("new-value");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(result.current[0]).toBe("new-value");
|
|
29
|
+
expect(localStorage.getItem("test-key")).toBe("new-value");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("handles localStorage errors gracefully", () => {
|
|
33
|
+
const origGetItem = Storage.prototype.getItem;
|
|
34
|
+
Storage.prototype.getItem = () => {
|
|
35
|
+
throw new Error("Quota exceeded");
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const { result } = renderHook(() => usePersistedState("test-key", "fallback"));
|
|
39
|
+
expect(result.current[0]).toBe("fallback");
|
|
40
|
+
|
|
41
|
+
Storage.prototype.getItem = origGetItem;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("maintains independent values for different keys", () => {
|
|
45
|
+
const { result: hookA } = renderHook(() => usePersistedState("key-a", "a-default"));
|
|
46
|
+
const { result: hookB } = renderHook(() => usePersistedState("key-b", "b-default"));
|
|
47
|
+
|
|
48
|
+
act(() => {
|
|
49
|
+
hookA.current[1]("a-updated");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(hookA.current[0]).toBe("a-updated");
|
|
53
|
+
expect(hookB.current[0]).toBe("b-default");
|
|
54
|
+
expect(localStorage.getItem("key-a")).toBe("a-updated");
|
|
55
|
+
expect(localStorage.getItem("key-b")).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* useState wrapper that persists to localStorage.
|
|
7
|
+
* Reads from storage in useEffect to avoid SSR hydration mismatch.
|
|
8
|
+
*/
|
|
9
|
+
export function usePersistedState<T extends string>(
|
|
10
|
+
key: string,
|
|
11
|
+
defaultValue: T
|
|
12
|
+
): [T, (value: T) => void] {
|
|
13
|
+
const [value, setValue] = useState<T>(defaultValue);
|
|
14
|
+
|
|
15
|
+
// Restore from localStorage on mount (client-only)
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
try {
|
|
18
|
+
const stored = localStorage.getItem(key);
|
|
19
|
+
if (stored !== null) {
|
|
20
|
+
setValue(stored as T);
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
// localStorage unavailable (e.g. private browsing)
|
|
24
|
+
}
|
|
25
|
+
}, [key]);
|
|
26
|
+
|
|
27
|
+
const setPersisted = useCallback(
|
|
28
|
+
(next: T) => {
|
|
29
|
+
setValue(next);
|
|
30
|
+
try {
|
|
31
|
+
localStorage.setItem(key, next);
|
|
32
|
+
} catch {
|
|
33
|
+
// localStorage unavailable
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
[key]
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return [value, setPersisted];
|
|
40
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
import { db } from "@/lib/db";
|
|
3
4
|
import { tasks, projects, agentLogs, notifications } from "@/lib/db/schema";
|
|
4
5
|
import { eq } from "drizzle-orm";
|
|
@@ -49,11 +50,13 @@ interface TaskUsageState extends UsageSnapshot {
|
|
|
49
50
|
scheduleId?: string | null;
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
|
|
53
|
-
behavior: "allow"
|
|
54
|
-
updatedInput
|
|
55
|
-
message
|
|
56
|
-
}
|
|
53
|
+
const toolPermissionResponseSchema = z.object({
|
|
54
|
+
behavior: z.enum(["allow", "deny"]),
|
|
55
|
+
updatedInput: z.unknown().optional(),
|
|
56
|
+
message: z.string().optional(),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
type ToolPermissionResponse = z.infer<typeof toolPermissionResponseSchema>;
|
|
57
60
|
|
|
58
61
|
const inFlightPermissionRequests = new Map<
|
|
59
62
|
string,
|
|
@@ -149,8 +152,15 @@ async function waitForToolPermissionResponse(
|
|
|
149
152
|
|
|
150
153
|
if (notification?.response) {
|
|
151
154
|
try {
|
|
152
|
-
|
|
153
|
-
|
|
155
|
+
const parsed = JSON.parse(notification.response);
|
|
156
|
+
const validated = toolPermissionResponseSchema.safeParse(parsed);
|
|
157
|
+
if (validated.success) {
|
|
158
|
+
return validated.data;
|
|
159
|
+
}
|
|
160
|
+
console.error("[claude-agent] Invalid permission response shape:", validated.error.message);
|
|
161
|
+
return { behavior: "deny", message: "Invalid response format" };
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error("[claude-agent] Failed to parse permission response:", err);
|
|
154
164
|
return { behavior: "deny", message: "Invalid response format" };
|
|
155
165
|
}
|
|
156
166
|
}
|
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Build the environment for the Claude Agent SDK subprocess.
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Always strips CLAUDECODE (prevents nested-session issues) and
|
|
5
|
+
* ANTHROPIC_API_KEY (prevents SDK from using API-key auth when
|
|
6
|
+
* OAuth mode is intended).
|
|
7
|
+
*
|
|
8
|
+
* - API-key mode: authEnv is provided → key gets merged back in via spread.
|
|
9
|
+
* - OAuth mode: authEnv is undefined → key stays stripped, SDK falls
|
|
10
|
+
* through to cached OAuth tokens from `claude login`.
|
|
4
11
|
*/
|
|
5
12
|
export function buildClaudeSdkEnv(
|
|
6
13
|
authEnv?: Record<string, string>
|
|
7
|
-
): Record<string, string>
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
14
|
+
): Record<string, string> {
|
|
15
|
+
const { CLAUDECODE, ANTHROPIC_API_KEY, ...cleanEnv } =
|
|
16
|
+
process.env as Record<string, string>;
|
|
17
|
+
|
|
18
|
+
if (authEnv) {
|
|
19
|
+
// API key mode — merge the provided key into clean env
|
|
20
|
+
return { ...cleanEnv, ...authEnv };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// OAuth mode — return env WITHOUT ANTHROPIC_API_KEY
|
|
24
|
+
// so the SDK subprocess uses cached OAuth tokens from Claude CLI
|
|
25
|
+
return cleanEnv;
|
|
12
26
|
}
|
|
@@ -39,11 +39,20 @@ async function collectResultText(
|
|
|
39
39
|
|
|
40
40
|
for await (const raw of response) {
|
|
41
41
|
usage = mergeUsageSnapshot(usage, extractUsageSnapshot(raw));
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
|
|
43
|
+
if (raw.type === "content_block_delta") {
|
|
44
|
+
const delta = raw.delta as Record<string, unknown> | undefined;
|
|
45
|
+
if (delta?.type === "text_delta" && typeof delta.text === "string") {
|
|
46
|
+
resultText += delta.text;
|
|
47
|
+
}
|
|
48
|
+
} else if (raw.type === "result" && "result" in raw) {
|
|
49
|
+
if (raw.is_error) {
|
|
50
|
+
throw new Error(typeof raw.result === "string" ? raw.result : "Agent SDK returned an error");
|
|
51
|
+
}
|
|
52
|
+
const result = raw.result;
|
|
53
|
+
if (typeof result === "string" && result.length > 0) {
|
|
54
|
+
resultText = result;
|
|
55
|
+
}
|
|
47
56
|
break;
|
|
48
57
|
}
|
|
49
58
|
}
|
|
@@ -230,12 +239,19 @@ async function runClaudeTaskAssist(
|
|
|
230
239
|
const startedAt = new Date();
|
|
231
240
|
let usage: UsageSnapshot = {};
|
|
232
241
|
|
|
242
|
+
const abortController = new AbortController();
|
|
243
|
+
const timeout = setTimeout(() => abortController.abort(), 30_000);
|
|
244
|
+
|
|
233
245
|
try {
|
|
234
246
|
const response = query({
|
|
235
247
|
prompt,
|
|
236
248
|
options: {
|
|
249
|
+
abortController,
|
|
250
|
+
includePartialMessages: true,
|
|
237
251
|
cwd: process.cwd(),
|
|
238
252
|
env: buildClaudeSdkEnv(authEnv),
|
|
253
|
+
allowedTools: [], // No tool use — pure text completion
|
|
254
|
+
maxTurns: 1, // Single turn only — no agentic loop
|
|
239
255
|
},
|
|
240
256
|
});
|
|
241
257
|
|
|
@@ -283,6 +299,8 @@ async function runClaudeTaskAssist(
|
|
|
283
299
|
finishedAt: new Date(),
|
|
284
300
|
});
|
|
285
301
|
throw error;
|
|
302
|
+
} finally {
|
|
303
|
+
clearTimeout(timeout);
|
|
286
304
|
}
|
|
287
305
|
}
|
|
288
306
|
|
|
@@ -643,7 +643,13 @@ async function runAssistTurn({
|
|
|
643
643
|
ephemeral: true,
|
|
644
644
|
})) as { thread: { id: string } };
|
|
645
645
|
|
|
646
|
+
const ASSIST_TIMEOUT_MS = 60_000;
|
|
647
|
+
|
|
646
648
|
const completion = new Promise<void>((resolve, reject) => {
|
|
649
|
+
client!.onProcessError = (error: Error) => {
|
|
650
|
+
reject(new Error(`Codex process died: ${error.message}`));
|
|
651
|
+
};
|
|
652
|
+
|
|
647
653
|
client!.onNotification = (notification: JsonRpcLikeNotification) => {
|
|
648
654
|
const params = asRecord(notification.params) ?? {};
|
|
649
655
|
applyUsageSnapshot(usage, params);
|
|
@@ -669,6 +675,13 @@ async function runAssistTurn({
|
|
|
669
675
|
};
|
|
670
676
|
});
|
|
671
677
|
|
|
678
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
679
|
+
setTimeout(
|
|
680
|
+
() => reject(new Error("Codex task assist timed out after 60s")),
|
|
681
|
+
ASSIST_TIMEOUT_MS
|
|
682
|
+
);
|
|
683
|
+
});
|
|
684
|
+
|
|
672
685
|
await client.request("turn/start", {
|
|
673
686
|
threadId: threadResponse.thread.id,
|
|
674
687
|
input: buildTurnInput(prompt),
|
|
@@ -676,7 +689,7 @@ async function runAssistTurn({
|
|
|
676
689
|
outputSchema: TASK_ASSIST_OUTPUT_SCHEMA,
|
|
677
690
|
});
|
|
678
691
|
|
|
679
|
-
await completion;
|
|
692
|
+
await Promise.race([completion, timeout]);
|
|
680
693
|
|
|
681
694
|
return { text: text.trim(), usage };
|
|
682
695
|
} finally {
|
package/src/lib/db/bootstrap.ts
CHANGED
|
@@ -193,44 +193,29 @@ export function bootstrapStagentDatabase(sqlite: Database.Database): void {
|
|
|
193
193
|
CREATE INDEX IF NOT EXISTS idx_learned_context_change_type ON learned_context(change_type);
|
|
194
194
|
`);
|
|
195
195
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
196
|
+
const addColumnIfMissing = (ddl: string) => {
|
|
197
|
+
try {
|
|
198
|
+
sqlite.exec(ddl);
|
|
199
|
+
} catch (err: unknown) {
|
|
200
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
201
|
+
if (!msg.includes("duplicate column")) {
|
|
202
|
+
console.error("[bootstrap] ALTER TABLE failed:", msg);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN agent_profile TEXT;`);
|
|
201
208
|
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_agent_profile ON tasks(agent_profile);`);
|
|
202
209
|
|
|
203
|
-
|
|
204
|
-
sqlite.exec(`ALTER TABLE tasks ADD COLUMN workflow_id TEXT REFERENCES workflows(id);`);
|
|
205
|
-
} catch {
|
|
206
|
-
// Column already exists.
|
|
207
|
-
}
|
|
210
|
+
addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN workflow_id TEXT REFERENCES workflows(id);`);
|
|
208
211
|
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workflow_id ON tasks(workflow_id);`);
|
|
209
212
|
|
|
210
|
-
|
|
211
|
-
sqlite.exec(`ALTER TABLE tasks ADD COLUMN schedule_id TEXT REFERENCES schedules(id);`);
|
|
212
|
-
} catch {
|
|
213
|
-
// Column already exists.
|
|
214
|
-
}
|
|
213
|
+
addColumnIfMissing(`ALTER TABLE tasks ADD COLUMN schedule_id TEXT REFERENCES schedules(id);`);
|
|
215
214
|
sqlite.exec(`CREATE INDEX IF NOT EXISTS idx_tasks_schedule_id ON tasks(schedule_id);`);
|
|
216
215
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
// Column already exists.
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
try {
|
|
224
|
-
sqlite.exec(`ALTER TABLE schedules ADD COLUMN assigned_agent TEXT;`);
|
|
225
|
-
} catch {
|
|
226
|
-
// Column already exists.
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
try {
|
|
230
|
-
sqlite.exec(`ALTER TABLE documents ADD COLUMN version INTEGER NOT NULL DEFAULT 1;`);
|
|
231
|
-
} catch {
|
|
232
|
-
// Column already exists.
|
|
233
|
-
}
|
|
216
|
+
addColumnIfMissing(`ALTER TABLE projects ADD COLUMN working_directory TEXT;`);
|
|
217
|
+
addColumnIfMissing(`ALTER TABLE schedules ADD COLUMN assigned_agent TEXT;`);
|
|
218
|
+
addColumnIfMissing(`ALTER TABLE documents ADD COLUMN version INTEGER NOT NULL DEFAULT 1;`);
|
|
234
219
|
}
|
|
235
220
|
|
|
236
221
|
export function hasLegacyStagentTables(sqlite: Database.Database): boolean {
|
|
@@ -42,8 +42,9 @@ export async function cleanupOrphanedUploads(): Promise<{
|
|
|
42
42
|
errors.push(`${filename}: ${err instanceof Error ? err.message : "unknown error"}`);
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
-
} catch {
|
|
46
|
-
// Upload directory may not exist yet
|
|
45
|
+
} catch (err) {
|
|
46
|
+
// Upload directory may not exist yet — log for visibility
|
|
47
|
+
console.error("[cleanup] Failed to read upload directory:", err);
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
return { deleted, errors };
|
|
@@ -23,7 +23,8 @@ export function parseNotificationToolInput(
|
|
|
23
23
|
return parsed && typeof parsed === "object"
|
|
24
24
|
? (parsed as PermissionToolInput)
|
|
25
25
|
: null;
|
|
26
|
-
} catch {
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.error("[permissions] Failed to parse notification tool input:", err);
|
|
27
28
|
return null;
|
|
28
29
|
}
|
|
29
30
|
}
|
|
@@ -163,7 +164,8 @@ export function getPermissionResponseLabel(response: string | null): string | nu
|
|
|
163
164
|
}
|
|
164
165
|
|
|
165
166
|
return null;
|
|
166
|
-
} catch {
|
|
167
|
+
} catch (err) {
|
|
168
|
+
console.error("[permissions] Failed to parse permission response:", err);
|
|
167
169
|
return null;
|
|
168
170
|
}
|
|
169
171
|
}
|
|
@@ -705,8 +705,8 @@ export async function executeChildTask(
|
|
|
705
705
|
|
|
706
706
|
try {
|
|
707
707
|
await executeTaskWithRuntime(taskId);
|
|
708
|
-
} catch {
|
|
709
|
-
|
|
708
|
+
} catch (err) {
|
|
709
|
+
console.error(`[workflow-engine] Runtime execution failed for task ${taskId}:`, err);
|
|
710
710
|
}
|
|
711
711
|
|
|
712
712
|
const [completedTask] = await db
|