@netnodeag/kraftwerk 0.43.0 → 0.45.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/README.md +68 -0
- package/dist/acp.d.ts +131 -1
- package/dist/acp.js +163 -2
- package/dist/harnesses/acp.js +9 -5
- package/dist/inspector/chat/acp.d.ts +48 -2
- package/dist/inspector/chat/acp.js +471 -47
- package/dist/inspector/chat/backend.d.ts +30 -3
- package/dist/inspector/chat/pi.js +4 -1
- package/dist/inspector/chat/sessions.d.ts +60 -2
- package/dist/inspector/chat/sessions.js +274 -16
- package/dist/inspector/chat/store.d.ts +12 -0
- package/dist/inspector/chat/store.js +46 -0
- package/dist/inspector/chat/types.d.ts +185 -2
- package/dist/inspector/runner.js +29 -4
- package/dist/inspector/runs.d.ts +5 -0
- package/dist/inspector/runs.js +56 -16
- package/dist/inspector/server.js +133 -7
- package/inspector/dist/assets/{dist-LuoAT8wo.js → dist-3XKl7K9N.js} +1 -1
- package/inspector/dist/assets/{dist-NNs7Vtl3.js → dist-BPzx-u5t.js} +1 -1
- package/inspector/dist/assets/dist-BnF1Rm2M.js +1 -0
- package/inspector/dist/assets/{dist-Rc-i9obk.js → dist-CGccQQRD.js} +1 -1
- package/inspector/dist/assets/{dist-C-Yu0i8M.js → dist-CIjxdHNW.js} +1 -1
- package/inspector/dist/assets/{dist-FHTVbzBi.js → dist-CdchVWU8.js} +1 -1
- package/inspector/dist/assets/{dist-2tkXRwU5.js → dist-DCZKo3cg.js} +1 -1
- package/inspector/dist/assets/{dist-CypKz49n.js → dist-DJnrih_T.js} +1 -1
- package/inspector/dist/assets/{dist-Ck7zPi0Z.js → dist-DWGd5Kj_.js} +1 -1
- package/inspector/dist/assets/{dist-BX5KQfrx.js → dist-DgaFP-8o.js} +1 -1
- package/inspector/dist/assets/{dist-BoDJE1PN.js → dist-bVw1IlxS.js} +1 -1
- package/inspector/dist/assets/{editor-BI5xRg8C.js → editor-DITzQiO1.js} +3 -3
- package/inspector/dist/assets/index-CS1G-A97.css +1 -0
- package/inspector/dist/assets/index-CiJ79dQy.js +219 -0
- package/inspector/dist/assets/roboto-latin-600-normal-CzqH9ZEY.woff2 +0 -0
- package/inspector/dist/assets/roboto-latin-600-normal-X4S5XP0J.woff +0 -0
- package/inspector/dist/index.html +2 -2
- package/package.json +2 -2
- package/inspector/dist/assets/dist-Q3GFwcs6.js +0 -1
- package/inspector/dist/assets/index-BUp72TiG.js +0 -218
- package/inspector/dist/assets/index-h5Vh1ZaF.css +0 -1
|
@@ -42,30 +42,213 @@ export interface ChatMeta {
|
|
|
42
42
|
scope: ChatScope;
|
|
43
43
|
/** Slug of the vibeable open in this chat's preview pane (cwd is its folder while set). */
|
|
44
44
|
vibeable?: string;
|
|
45
|
+
/**
|
|
46
|
+
* The agent's own session id per seat ("main", or the agent slug in a
|
|
47
|
+
* channel): after a restart or the idle reaper, the next backend resumes
|
|
48
|
+
* it (session/resume) so the agent keeps its context instead of starting
|
|
49
|
+
* over with the transcript as a summary.
|
|
50
|
+
*/
|
|
51
|
+
sessions?: Record<string, string>;
|
|
45
52
|
createdAt: string;
|
|
46
53
|
updatedAt: string;
|
|
47
54
|
}
|
|
48
|
-
/**
|
|
49
|
-
export
|
|
55
|
+
/** A context compaction the agent ran, as reported on its synthetic tool call (`_meta.contextCompaction`). */
|
|
56
|
+
export interface Compaction {
|
|
57
|
+
trigger?: "manual" | "automatic";
|
|
58
|
+
preTokens?: number;
|
|
59
|
+
postTokens?: number;
|
|
60
|
+
durationMs?: number;
|
|
61
|
+
error?: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A failure the agent's session ran into, as the harness reports it
|
|
65
|
+
* (AIR `sessionFailure`): what broke, how bad, and what a human can do.
|
|
66
|
+
* The same `id` with a higher `revision` replaces an earlier report.
|
|
67
|
+
*/
|
|
68
|
+
export interface SessionFailure {
|
|
69
|
+
id: string;
|
|
70
|
+
revision: number;
|
|
71
|
+
category: "connection" | "access" | "limit" | "request" | "service" | "unknown";
|
|
72
|
+
severity: "warning" | "error";
|
|
73
|
+
title: string;
|
|
74
|
+
details?: string;
|
|
75
|
+
/** Machine-readable refinement of the failure (e.g. why a login was refused). */
|
|
76
|
+
reason?: string;
|
|
77
|
+
actions: Array<"retry" | "login" | "new_session">;
|
|
78
|
+
}
|
|
79
|
+
/** One step of the agent's plan (Claude's todo list), as ACP reports it. */
|
|
80
|
+
export interface PlanEntry {
|
|
81
|
+
content: string;
|
|
82
|
+
priority: "high" | "medium" | "low";
|
|
83
|
+
status: "pending" | "in_progress" | "completed";
|
|
84
|
+
}
|
|
85
|
+
/** A slash command the agent offers (`available_commands_update`). */
|
|
86
|
+
export interface AgentCommand {
|
|
87
|
+
name: string;
|
|
88
|
+
description: string;
|
|
89
|
+
/** What the command takes after its name, if anything. */
|
|
90
|
+
hint?: string;
|
|
91
|
+
}
|
|
92
|
+
/** One session setting the agent lets the client change (`configOptions`). */
|
|
93
|
+
export type ConfigOption = {
|
|
94
|
+
id: string;
|
|
95
|
+
name: string;
|
|
96
|
+
description?: string;
|
|
97
|
+
/** model | thought_level | model_config | mode | ... (agent-defined) */
|
|
98
|
+
category?: string;
|
|
99
|
+
} & ({
|
|
100
|
+
type: "select";
|
|
101
|
+
value: string;
|
|
102
|
+
choices: Array<{
|
|
103
|
+
value: string;
|
|
104
|
+
name: string;
|
|
105
|
+
description?: string;
|
|
106
|
+
group?: string;
|
|
107
|
+
}>;
|
|
108
|
+
} | {
|
|
109
|
+
type: "boolean";
|
|
110
|
+
value: boolean;
|
|
111
|
+
});
|
|
112
|
+
/** Who the agent is signed in as (`_auth/status_update`). */
|
|
113
|
+
export interface AuthStatus {
|
|
114
|
+
kind: "account" | "api_key" | "gateway" | "external" | "none";
|
|
115
|
+
label: string;
|
|
116
|
+
detail?: string;
|
|
117
|
+
email?: string;
|
|
118
|
+
organization?: string;
|
|
119
|
+
plan?: string;
|
|
120
|
+
}
|
|
121
|
+
/** One field of a question the agent asks (an ACP form elicitation, e.g. Claude's AskUserQuestion). */
|
|
122
|
+
export interface ElicitationField {
|
|
123
|
+
key: string;
|
|
124
|
+
title?: string;
|
|
125
|
+
description?: string;
|
|
126
|
+
kind: "select" | "multiselect" | "text" | "number" | "boolean";
|
|
127
|
+
options?: Array<{
|
|
128
|
+
value: string;
|
|
129
|
+
label: string;
|
|
130
|
+
description?: string;
|
|
131
|
+
}>;
|
|
132
|
+
required?: boolean;
|
|
133
|
+
/** A free-text alternative to the select above it ("Other"). */
|
|
134
|
+
custom?: boolean;
|
|
135
|
+
}
|
|
136
|
+
/** A file that travels with a message (dropped or pasted into the composer); stored under the chat's folder. */
|
|
137
|
+
export interface Attachment {
|
|
138
|
+
/** File name inside the chat's attachments/ folder (unique, safe). */
|
|
139
|
+
name: string;
|
|
140
|
+
mimeType: string;
|
|
141
|
+
size: number;
|
|
142
|
+
}
|
|
143
|
+
export type ElicitationAnswer = {
|
|
144
|
+
action: "accept";
|
|
145
|
+
content: Record<string, string | number | boolean | string[]>;
|
|
146
|
+
} | {
|
|
147
|
+
action: "decline";
|
|
148
|
+
} | {
|
|
149
|
+
action: "cancel";
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* One thread event; `seq` orders them and drives SSE resume (?after=seq).
|
|
153
|
+
* `subagent` on text/thought/tool events names the child session that
|
|
154
|
+
* produced them (see the `subagent` event); absent = the main agent.
|
|
155
|
+
*/
|
|
156
|
+
export type ChatEvent =
|
|
157
|
+
/** `steered`: handed into the running turn instead of starting the next one. */
|
|
158
|
+
{
|
|
50
159
|
type: "user_message";
|
|
51
160
|
text: string;
|
|
161
|
+
steered?: boolean;
|
|
162
|
+
attachments?: Attachment[];
|
|
52
163
|
} | {
|
|
53
164
|
type: "text";
|
|
54
165
|
text: string;
|
|
166
|
+
subagent?: string;
|
|
55
167
|
} | {
|
|
56
168
|
type: "thought";
|
|
57
169
|
text: string;
|
|
170
|
+
subagent?: string;
|
|
58
171
|
} | {
|
|
59
172
|
type: "tool_call";
|
|
60
173
|
callId: string;
|
|
61
174
|
title: string;
|
|
62
175
|
kind?: string;
|
|
63
176
|
status?: string;
|
|
177
|
+
subagent?: string;
|
|
178
|
+
compaction?: Compaction;
|
|
64
179
|
} | {
|
|
65
180
|
type: "tool_update";
|
|
66
181
|
callId: string;
|
|
67
182
|
title?: string;
|
|
68
183
|
status?: string;
|
|
184
|
+
subagent?: string;
|
|
185
|
+
compaction?: Compaction;
|
|
186
|
+
}
|
|
187
|
+
/** The agent delegated to a subagent running as its own session; its events follow tagged with `subagent: sessionId`. */
|
|
188
|
+
| {
|
|
189
|
+
type: "subagent";
|
|
190
|
+
sessionId: string;
|
|
191
|
+
name: string;
|
|
192
|
+
task: string;
|
|
193
|
+
} | {
|
|
194
|
+
type: "subagent_state";
|
|
195
|
+
sessionId: string;
|
|
196
|
+
state: "completed" | "failed" | "cancelled" | "disconnected";
|
|
197
|
+
}
|
|
198
|
+
/** Background work the agent started (a backgrounded shell, a monitor, a workflow) — outlives the tool call that started it. */
|
|
199
|
+
| {
|
|
200
|
+
type: "task";
|
|
201
|
+
taskId: string;
|
|
202
|
+
name: string;
|
|
203
|
+
taskType: string;
|
|
204
|
+
description: string;
|
|
205
|
+
toolCallId?: string;
|
|
206
|
+
canStop?: boolean;
|
|
207
|
+
} | ({
|
|
208
|
+
type: "failure";
|
|
209
|
+
} & SessionFailure)
|
|
210
|
+
/** The agent's current plan; null when it dropped it. Replaces the previous one. */
|
|
211
|
+
| {
|
|
212
|
+
type: "plan";
|
|
213
|
+
entries: PlanEntry[] | null;
|
|
214
|
+
}
|
|
215
|
+
/** Context window use (tokens used of size) and the session's cost so far. */
|
|
216
|
+
| {
|
|
217
|
+
type: "usage";
|
|
218
|
+
used: number;
|
|
219
|
+
size: number;
|
|
220
|
+
costUsd?: number;
|
|
221
|
+
} | {
|
|
222
|
+
type: "commands";
|
|
223
|
+
commands: AgentCommand[];
|
|
224
|
+
} | {
|
|
225
|
+
type: "config";
|
|
226
|
+
options: ConfigOption[];
|
|
227
|
+
} | ({
|
|
228
|
+
type: "auth";
|
|
229
|
+
} & AuthStatus)
|
|
230
|
+
/** What the agent says it changed this turn (AIR file change report); `unavailable` names why there is no list. */
|
|
231
|
+
| {
|
|
232
|
+
type: "files_changed";
|
|
233
|
+
paths: string[];
|
|
234
|
+
complete: boolean;
|
|
235
|
+
uncertainty?: string;
|
|
236
|
+
unavailable?: string;
|
|
237
|
+
} | {
|
|
238
|
+
type: "elicitation_request";
|
|
239
|
+
requestId: string;
|
|
240
|
+
message: string;
|
|
241
|
+
fields: ElicitationField[];
|
|
242
|
+
} | ({
|
|
243
|
+
type: "elicitation_resolved";
|
|
244
|
+
requestId: string;
|
|
245
|
+
} & ElicitationAnswer) | {
|
|
246
|
+
type: "task_update";
|
|
247
|
+
taskId: string;
|
|
248
|
+
state?: "running" | "paused" | "completed" | "failed" | "stopped";
|
|
249
|
+
description?: string;
|
|
250
|
+
summary?: string;
|
|
251
|
+
outputFilePath?: string;
|
|
69
252
|
} | {
|
|
70
253
|
type: "permission_request";
|
|
71
254
|
requestId: string;
|
package/dist/inspector/runner.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
-
import { mkdirSync, openSync, closeSync } from "node:fs";
|
|
2
|
+
import { mkdirSync, openSync, closeSync, writeFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { getProjectRoot } from "./context.js";
|
|
5
5
|
import { RUN_ID_RE, getRun, safeRunDir } from "./runs.js";
|
|
@@ -28,6 +28,8 @@ export function dockerStatus() {
|
|
|
28
28
|
});
|
|
29
29
|
return { available: true, image: image.status === 0 };
|
|
30
30
|
}
|
|
31
|
+
/** Launchers this inspector started and that have not exited yet, by run id. */
|
|
32
|
+
const live = new Map();
|
|
31
33
|
export function triggerRun(opts) {
|
|
32
34
|
if (opts.sandbox) {
|
|
33
35
|
const docker = dockerStatus();
|
|
@@ -55,10 +57,21 @@ export function triggerRun(opts) {
|
|
|
55
57
|
});
|
|
56
58
|
child.unref();
|
|
57
59
|
closeSync(log);
|
|
60
|
+
live.set(runId, child);
|
|
58
61
|
// Detached, but as long as the inspector lives it still hears the exit:
|
|
59
62
|
// that is when the run's outcome goes to the bell (read from the trace,
|
|
60
|
-
// the exit code alone does not say whether a gate blocked).
|
|
61
|
-
|
|
63
|
+
// the exit code alone does not say whether a gate blocked). The exit
|
|
64
|
+
// code also lands in trigger.json, so a launcher that died before the
|
|
65
|
+
// framework wrote a trace (missing env var, npx failure) shows as failed
|
|
66
|
+
// instead of running until the stale timeout.
|
|
67
|
+
child.on("exit", (code, signal) => {
|
|
68
|
+
live.delete(runId);
|
|
69
|
+
try {
|
|
70
|
+
writeFileSync(path.join(runDir, "trigger.json"), JSON.stringify({ exitCode: code ?? 1, signal, finishedAt: new Date().toISOString() }, null, 2) + "\n");
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
/* run dir removed meanwhile */
|
|
74
|
+
}
|
|
62
75
|
void getRun(runId)
|
|
63
76
|
.then((run) => {
|
|
64
77
|
const ok = run?.status === "ok";
|
|
@@ -76,8 +89,20 @@ export function triggerRun(opts) {
|
|
|
76
89
|
return { runId };
|
|
77
90
|
}
|
|
78
91
|
export function stopRun(runId) {
|
|
79
|
-
// Sandboxed runs run in container kw-<runId>; docker stop ends them.
|
|
80
92
|
if (!RUN_ID_RE.test(runId))
|
|
81
93
|
return false;
|
|
94
|
+
// A local run this inspector launched: the launcher is detached into its
|
|
95
|
+
// own process group, so the negative pid ends npx and the framework under it.
|
|
96
|
+
const child = live.get(runId);
|
|
97
|
+
if (child?.pid && child.exitCode == null) {
|
|
98
|
+
try {
|
|
99
|
+
process.kill(-child.pid, "SIGTERM");
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* group already gone — fall through to docker */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Sandboxed runs run in container kw-<runId>; docker stop ends them.
|
|
82
107
|
return (spawnSync("docker", ["stop", `kw-${runId}`], { stdio: "ignore", timeout: 30_000 }).status === 0);
|
|
83
108
|
}
|
package/dist/inspector/runs.d.ts
CHANGED
|
@@ -58,3 +58,8 @@ export declare function readRunFile(id: string, name: string): Promise<{
|
|
|
58
58
|
absPath: string;
|
|
59
59
|
size: number;
|
|
60
60
|
} | null>;
|
|
61
|
+
/**
|
|
62
|
+
* Remove a run folder. A run that still looks live is refused: its process
|
|
63
|
+
* would keep writing into a folder that no longer exists — stop it first.
|
|
64
|
+
*/
|
|
65
|
+
export declare function deleteRun(id: string): Promise<"deleted" | "running" | "missing">;
|
package/dist/inspector/runs.js
CHANGED
|
@@ -29,7 +29,7 @@ async function readTrace(runDir) {
|
|
|
29
29
|
}
|
|
30
30
|
return events;
|
|
31
31
|
}
|
|
32
|
-
function analyse(events) {
|
|
32
|
+
function analyse(events, fallbackTs) {
|
|
33
33
|
const runStart = events.find((e) => e.event === "run_start");
|
|
34
34
|
const summary = events.find((e) => e.event === "run_summary");
|
|
35
35
|
const phases = [];
|
|
@@ -118,42 +118,69 @@ function analyse(events) {
|
|
|
118
118
|
}
|
|
119
119
|
const failed = phases.some((p) => p.status === "failed" || p.status === "blocked");
|
|
120
120
|
const lastTs = events.length ? events[events.length - 1].ts : undefined;
|
|
121
|
+
// A run folder without a trace (the launcher died before the framework
|
|
122
|
+
// wrote anything — missing env var, npx failure) has no event to age; the
|
|
123
|
+
// newest file in the folder stands in, so it cannot stay "running" forever.
|
|
124
|
+
const activityTs = lastTs ?? fallbackTs;
|
|
121
125
|
let status;
|
|
122
126
|
if (summary)
|
|
123
127
|
status = failed ? "failed" : "ok";
|
|
124
128
|
else if (failed)
|
|
125
129
|
status = "failed";
|
|
126
|
-
else if (
|
|
127
|
-
status = "aborted";
|
|
130
|
+
else if (activityTs && Date.now() - Date.parse(activityTs) > STALE_MS)
|
|
131
|
+
status = events.length ? "aborted" : "failed";
|
|
128
132
|
else
|
|
129
133
|
status = "running";
|
|
130
134
|
return { runStart, summary, phases, steps, status, lastTs };
|
|
131
135
|
}
|
|
132
136
|
/**
|
|
133
137
|
* Sandboxed runs write runner.json (exit code recorded when the container
|
|
134
|
-
* ends)
|
|
135
|
-
*
|
|
136
|
-
*
|
|
138
|
+
* ends) and UI-triggered runs write trigger.json (exit code of the launcher,
|
|
139
|
+
* see runner.ts). If the trace looks "running" but the process already
|
|
140
|
+
* exited — e.g. it crashed before any phase_end, or never wrote a trace —
|
|
141
|
+
* trust the exit code instead of waiting for the 15-minute stale timeout.
|
|
137
142
|
*/
|
|
138
143
|
async function applyRunnerVerdict(runDir, a) {
|
|
139
144
|
if (a.status !== "running")
|
|
140
145
|
return a;
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
146
|
+
for (const name of ["runner.json", "trigger.json"]) {
|
|
147
|
+
try {
|
|
148
|
+
const meta = JSON.parse(await fs.readFile(path.join(runDir, name), "utf8"));
|
|
149
|
+
if (meta.exitCode == null)
|
|
150
|
+
continue;
|
|
144
151
|
a.status = meta.exitCode === 0 ? "ok" : "failed";
|
|
145
152
|
if (meta.exitCode !== 0) {
|
|
146
153
|
for (const p of a.phases)
|
|
147
154
|
if (p.status === "running")
|
|
148
155
|
p.status = "failed";
|
|
149
156
|
}
|
|
157
|
+
return a;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
/* file absent (local CLI run) or unreadable — try the next one */
|
|
150
161
|
}
|
|
151
|
-
}
|
|
152
|
-
catch {
|
|
153
|
-
/* no runner.json (local run) or unreadable — keep trace-based status */
|
|
154
162
|
}
|
|
155
163
|
return a;
|
|
156
164
|
}
|
|
165
|
+
/** Newest mtime among the run folder's files — the age of a run that never wrote a trace. */
|
|
166
|
+
async function newestFileTs(runDir) {
|
|
167
|
+
const names = await fs.readdir(runDir).catch(() => []);
|
|
168
|
+
let newest = 0;
|
|
169
|
+
for (const name of names) {
|
|
170
|
+
const st = await fs.stat(path.join(runDir, name)).catch(() => null);
|
|
171
|
+
if (st && st.mtimeMs > newest)
|
|
172
|
+
newest = st.mtimeMs;
|
|
173
|
+
}
|
|
174
|
+
return newest ? new Date(newest).toISOString() : undefined;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The workflow slug baked into the run id ("2026-08-25-1432-07-<slug>", see
|
|
178
|
+
* newRunId) — the only name a run has when the trace was never written.
|
|
179
|
+
*/
|
|
180
|
+
function workflowFromId(id) {
|
|
181
|
+
const m = /^\d{4}-\d{2}-\d{2}-\d{4}-\d{2}-(.+)$/.exec(id);
|
|
182
|
+
return m?.[1];
|
|
183
|
+
}
|
|
157
184
|
function shortenPath(p) {
|
|
158
185
|
const parts = p.split("/");
|
|
159
186
|
return parts.length > 2 ? parts.slice(-2).join("/") : p;
|
|
@@ -171,12 +198,12 @@ export async function listRuns() {
|
|
|
171
198
|
if (!st?.isDirectory())
|
|
172
199
|
return null;
|
|
173
200
|
const events = await readTrace(runDir);
|
|
174
|
-
const { runStart, summary, phases, steps, status, lastTs } = await applyRunnerVerdict(runDir, analyse(events));
|
|
201
|
+
const { runStart, summary, phases, steps, status, lastTs } = await applyRunnerVerdict(runDir, analyse(events, events.length ? undefined : await newestFileTs(runDir)));
|
|
175
202
|
const done = phases.filter((p) => p.status === "ok").length;
|
|
176
203
|
const current = phases.find((p) => p.status === "running")?.phase;
|
|
177
204
|
return {
|
|
178
205
|
id,
|
|
179
|
-
workflow: runStart?.workflow,
|
|
206
|
+
workflow: runStart?.workflow ?? workflowFromId(id),
|
|
180
207
|
request: runStart?.request,
|
|
181
208
|
status,
|
|
182
209
|
startedAt: events[0]?.ts,
|
|
@@ -207,7 +234,7 @@ export async function getRun(id) {
|
|
|
207
234
|
if (!st?.isDirectory())
|
|
208
235
|
return null;
|
|
209
236
|
const events = await readTrace(runDir);
|
|
210
|
-
const { runStart, summary, phases, steps, status, lastTs } = await applyRunnerVerdict(runDir, analyse(events));
|
|
237
|
+
const { runStart, summary, phases, steps, status, lastTs } = await applyRunnerVerdict(runDir, analyse(events, events.length ? undefined : await newestFileTs(runDir)));
|
|
211
238
|
const names = await fs.readdir(runDir);
|
|
212
239
|
const files = [];
|
|
213
240
|
for (const name of names) {
|
|
@@ -218,7 +245,7 @@ export async function getRun(id) {
|
|
|
218
245
|
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
219
246
|
return {
|
|
220
247
|
id,
|
|
221
|
-
workflow: runStart?.workflow,
|
|
248
|
+
workflow: runStart?.workflow ?? workflowFromId(id),
|
|
222
249
|
description: runStart?.description,
|
|
223
250
|
request: runStart?.request,
|
|
224
251
|
status,
|
|
@@ -244,3 +271,16 @@ export async function readRunFile(id, name) {
|
|
|
244
271
|
return null;
|
|
245
272
|
return { absPath, size: st.size };
|
|
246
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* Remove a run folder. A run that still looks live is refused: its process
|
|
276
|
+
* would keep writing into a folder that no longer exists — stop it first.
|
|
277
|
+
*/
|
|
278
|
+
export async function deleteRun(id) {
|
|
279
|
+
const run = await getRun(id);
|
|
280
|
+
if (!run)
|
|
281
|
+
return "missing";
|
|
282
|
+
if (run.status === "running")
|
|
283
|
+
return "running";
|
|
284
|
+
await fs.rm(safeRunDir(id), { recursive: true, force: true });
|
|
285
|
+
return "deleted";
|
|
286
|
+
}
|
package/dist/inspector/server.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { existsSync, promises as fs } from "node:fs";
|
|
2
2
|
import http from "node:http";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { attachmentPath, saveAttachment } from "./chat/store.js";
|
|
4
5
|
import { setOutputDir, setProjectRoot, getOutputDir, getProjectRoot } from "./context.js";
|
|
5
6
|
import { resolveProject } from "../config.js";
|
|
6
|
-
import { listRuns, getRun, readRunFile } from "./runs.js";
|
|
7
|
+
import { listRuns, getRun, readRunFile, deleteRun } from "./runs.js";
|
|
7
8
|
import { listWorkflows, getWorkflow } from "./workflows.js";
|
|
8
9
|
import { dockerStatus, triggerRun, stopRun } from "./runner.js";
|
|
9
10
|
import { clearNotifications, listNotifications, markNotificationsRead } from "./notifications.js";
|
|
10
11
|
import { startDiagnosis } from "./diagnose.js";
|
|
11
12
|
import { deleteChannel, getChannel, listChannels, saveChannel, channelsRoot } from "./channels.js";
|
|
12
|
-
import { cancelChat, createChat, deleteChat, disposeAllBackends, getChat, listChats, postMessage, resolvePermission, setChatVibeable, subscribeChat, convertChatToChannel, dropChannelSeats, ensureChannelChat } from "./chat/sessions.js";
|
|
13
|
+
import { cancelChat, createChat, forkChat, resetSession, answerElicitation, steerChat, stopChatTask, setChatConfig, listAgentSessionsFor, deleteChat, disposeAllBackends, getChat, listChats, postMessage, resolvePermission, setChatVibeable, subscribeChat, convertChatToChannel, dropChannelSeats, ensureChannelChat } from "./chat/sessions.js";
|
|
13
14
|
import { bundleDetail, conceptDetail, createBundle, knowledgeIndex, putConcept, verifyFromUi, } from "./knowledge.js";
|
|
14
15
|
import { deleteAgent, getAgent, listAgents, saveAgent, setAgentArchived, agentsRoot, } from "./agents.js";
|
|
15
16
|
import { deleteAgentSkill, getSkill, listAgentSkills, listSkills, readSkill, saveAgentSkill, skillsRoot, } from "./skills.js";
|
|
@@ -54,6 +55,24 @@ function json(res, body, status = 200) {
|
|
|
54
55
|
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
55
56
|
res.end(JSON.stringify(body));
|
|
56
57
|
}
|
|
58
|
+
/** A raw upload body (attachments), capped at `max` bytes. */
|
|
59
|
+
function readRawBody(req, max) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const chunks = [];
|
|
62
|
+
let size = 0;
|
|
63
|
+
req.on("data", (c) => {
|
|
64
|
+
size += c.length;
|
|
65
|
+
if (size > max) {
|
|
66
|
+
reject(new Error(`file too large (max ${Math.round(max / 1_000_000)} MB)`));
|
|
67
|
+
req.destroy();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
chunks.push(c);
|
|
71
|
+
});
|
|
72
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
73
|
+
req.on("error", reject);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
57
76
|
function readBody(req) {
|
|
58
77
|
return new Promise((resolve, reject) => {
|
|
59
78
|
let data = "";
|
|
@@ -618,7 +637,22 @@ async function handleApi(req, res, url) {
|
|
|
618
637
|
if (seg.length === 4 && seg[1] === "runs" && seg[3] === "stop" && method === "POST") {
|
|
619
638
|
return stopRun(seg[2])
|
|
620
639
|
? json(res, { stopped: true })
|
|
621
|
-
: json(res, { error: "
|
|
640
|
+
: json(res, { error: "nothing to stop: no launcher of this inspector and no sandbox container for this run" }, 404);
|
|
641
|
+
}
|
|
642
|
+
// DELETE /api/runs/:id — refused while the run is live (stop it first)
|
|
643
|
+
if (seg.length === 3 && seg[1] === "runs" && method === "DELETE") {
|
|
644
|
+
let outcome;
|
|
645
|
+
try {
|
|
646
|
+
outcome = await deleteRun(seg[2]);
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
return json(res, { error: "invalid run id" }, 400);
|
|
650
|
+
}
|
|
651
|
+
if (outcome === "missing")
|
|
652
|
+
return json(res, { error: "not found" }, 404);
|
|
653
|
+
if (outcome === "running")
|
|
654
|
+
return json(res, { error: "run is still running — stop it first" }, 409);
|
|
655
|
+
return json(res, { deleted: true });
|
|
622
656
|
}
|
|
623
657
|
// GET /api/workflows
|
|
624
658
|
if (seg.length === 2 && seg[1] === "workflows" && method === "GET") {
|
|
@@ -902,13 +936,25 @@ async function handleApi(req, res, url) {
|
|
|
902
936
|
return json(res, { error: "agent must be claude, codex, or pi" }, 400);
|
|
903
937
|
}
|
|
904
938
|
try {
|
|
905
|
-
return json(res, await createChat({ agent, scope }));
|
|
939
|
+
return json(res, await createChat({ agent, scope, ...(typeof body.resume === "string" && body.resume ? { resume: body.resume } : {}) }));
|
|
906
940
|
}
|
|
907
941
|
catch (err) {
|
|
908
942
|
return json(res, { error: err.message }, 400);
|
|
909
943
|
}
|
|
910
944
|
}
|
|
911
945
|
}
|
|
946
|
+
// GET /api/agent-sessions?agent=claude|codex — the agent's own sessions in the project root
|
|
947
|
+
if (seg.length === 2 && seg[1] === "agent-sessions" && method === "GET") {
|
|
948
|
+
const agent = url.searchParams.get("agent") ?? "claude";
|
|
949
|
+
if (!["claude", "codex", "pi"].includes(agent))
|
|
950
|
+
return json(res, { error: "agent must be claude, codex, or pi" }, 400);
|
|
951
|
+
try {
|
|
952
|
+
return json(res, { sessions: await listAgentSessionsFor(agent) });
|
|
953
|
+
}
|
|
954
|
+
catch (err) {
|
|
955
|
+
return json(res, { error: err.message }, 502);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
912
958
|
// DELETE /api/chats/:id
|
|
913
959
|
if (seg.length === 3 && seg[1] === "chats" && method === "DELETE") {
|
|
914
960
|
try {
|
|
@@ -968,6 +1014,60 @@ async function handleApi(req, res, url) {
|
|
|
968
1014
|
return json(res, { error: err.message }, 400);
|
|
969
1015
|
}
|
|
970
1016
|
}
|
|
1017
|
+
// POST /api/chats/:id/fork — a new chat continuing a copy of this one's session
|
|
1018
|
+
if (seg.length === 4 && seg[1] === "chats" && seg[3] === "fork" && method === "POST") {
|
|
1019
|
+
try {
|
|
1020
|
+
const result = await forkChat(seg[2]);
|
|
1021
|
+
return result.error ? json(res, { error: result.error }, result.status ?? 400) : json(res, result.meta);
|
|
1022
|
+
}
|
|
1023
|
+
catch (err) {
|
|
1024
|
+
return json(res, { error: err.message }, 400);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
// POST /api/chats/:id/attachments — raw file body; x-file-name names it. Returns the stored attachment.
|
|
1028
|
+
if (seg.length === 4 && seg[1] === "chats" && seg[3] === "attachments" && method === "POST") {
|
|
1029
|
+
try {
|
|
1030
|
+
if (!(await getChat(seg[2])))
|
|
1031
|
+
return json(res, { error: "not found" }, 404);
|
|
1032
|
+
const data = await readRawBody(req, 25_000_000);
|
|
1033
|
+
if (data.length === 0)
|
|
1034
|
+
return json(res, { error: "empty file" }, 400);
|
|
1035
|
+
const mimeType = (req.headers["content-type"] ?? "application/octet-stream").split(";")[0].trim();
|
|
1036
|
+
const name = await saveAttachment(seg[2], decodeURIComponent(String(req.headers["x-file-name"] ?? "file")), data);
|
|
1037
|
+
return json(res, { name, mimeType, size: data.length });
|
|
1038
|
+
}
|
|
1039
|
+
catch (err) {
|
|
1040
|
+
return json(res, { error: err.message }, 400);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
// GET /api/chats/:id/attachments/:name — the stored file
|
|
1044
|
+
if (seg.length === 5 && seg[1] === "chats" && seg[3] === "attachments" && method === "GET") {
|
|
1045
|
+
try {
|
|
1046
|
+
const file = attachmentPath(seg[2], seg[4]);
|
|
1047
|
+
const ext = path.extname(file).toLowerCase();
|
|
1048
|
+
const data = await fs.readFile(file);
|
|
1049
|
+
res.writeHead(200, {
|
|
1050
|
+
"content-type": MIME[ext] ?? "application/octet-stream",
|
|
1051
|
+
"content-length": data.length,
|
|
1052
|
+
"cache-control": "private, max-age=86400",
|
|
1053
|
+
"x-content-type-options": "nosniff",
|
|
1054
|
+
});
|
|
1055
|
+
return void res.end(data);
|
|
1056
|
+
}
|
|
1057
|
+
catch {
|
|
1058
|
+
return json(res, { error: "not found" }, 404);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
// POST /api/chats/:id/reset-session — forget the agent's session; the next message starts fresh
|
|
1062
|
+
if (seg.length === 4 && seg[1] === "chats" && seg[3] === "reset-session" && method === "POST") {
|
|
1063
|
+
try {
|
|
1064
|
+
const result = await resetSession(seg[2]);
|
|
1065
|
+
return result.error ? json(res, { error: result.error }, result.status ?? 400) : json(res, result.meta);
|
|
1066
|
+
}
|
|
1067
|
+
catch (err) {
|
|
1068
|
+
return json(res, { error: err.message }, 400);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
971
1071
|
// POST /api/chats/:id/{message,permission,cancel}
|
|
972
1072
|
if (seg.length === 4 && seg[1] === "chats" && method === "POST") {
|
|
973
1073
|
let body = {};
|
|
@@ -981,18 +1081,44 @@ async function handleApi(req, res, url) {
|
|
|
981
1081
|
}
|
|
982
1082
|
try {
|
|
983
1083
|
let result;
|
|
1084
|
+
const attachments = Array.isArray(body.attachments)
|
|
1085
|
+
? body.attachments
|
|
1086
|
+
.filter((a) => a && typeof a.name === "string" && typeof a.mimeType === "string")
|
|
1087
|
+
.map((a) => ({ name: String(a.name), mimeType: String(a.mimeType), size: Number(a.size) || 0 }))
|
|
1088
|
+
: [];
|
|
984
1089
|
if (seg[3] === "message") {
|
|
985
1090
|
const text = String(body.text ?? "").trim();
|
|
986
|
-
if (!text)
|
|
1091
|
+
if (!text && attachments.length === 0)
|
|
987
1092
|
return json(res, { error: "text is required" }, 400);
|
|
988
1093
|
// Channels: the poster's display name signs the message.
|
|
989
|
-
result = await postMessage(seg[2], text, { from: typeof body.from === "string" ? body.from : undefined });
|
|
1094
|
+
result = await postMessage(seg[2], text || "(see attached)", { from: typeof body.from === "string" ? body.from : undefined, attachments });
|
|
990
1095
|
}
|
|
991
1096
|
else if (seg[3] === "permission") {
|
|
992
1097
|
result = await resolvePermission(seg[2], String(body.requestId ?? ""), body.optionId == null ? null : String(body.optionId));
|
|
993
1098
|
}
|
|
994
1099
|
else if (seg[3] === "cancel") {
|
|
995
|
-
|
|
1100
|
+
// Channels: `agent` stops one member; without it every agent in the chat.
|
|
1101
|
+
result = await cancelChat(seg[2], typeof body.agent === "string" && body.agent ? body.agent : undefined);
|
|
1102
|
+
}
|
|
1103
|
+
else if (seg[3] === "steer") {
|
|
1104
|
+
const text = String(body.text ?? "").trim();
|
|
1105
|
+
if (!text && attachments.length === 0)
|
|
1106
|
+
return json(res, { error: "text is required" }, 400);
|
|
1107
|
+
result = await steerChat(seg[2], text || "(see attached)", attachments);
|
|
1108
|
+
}
|
|
1109
|
+
else if (seg[3] === "elicitation") {
|
|
1110
|
+
const action = body.action === "accept" || body.action === "decline" || body.action === "cancel" ? body.action : null;
|
|
1111
|
+
if (!action)
|
|
1112
|
+
return json(res, { error: "action must be accept, decline, or cancel" }, 400);
|
|
1113
|
+
const content = body.content && typeof body.content === "object" ? body.content : {};
|
|
1114
|
+
result = await answerElicitation(seg[2], String(body.requestId ?? ""), action === "accept" ? { action, content } : { action });
|
|
1115
|
+
}
|
|
1116
|
+
else if (seg[3] === "task-stop") {
|
|
1117
|
+
result = await stopChatTask(seg[2], String(body.taskId ?? ""));
|
|
1118
|
+
}
|
|
1119
|
+
else if (seg[3] === "config") {
|
|
1120
|
+
const value = typeof body.value === "boolean" ? body.value : String(body.value ?? "");
|
|
1121
|
+
result = await setChatConfig(seg[2], String(body.configId ?? ""), value);
|
|
996
1122
|
}
|
|
997
1123
|
else {
|
|
998
1124
|
return json(res, { error: "not found" }, 404);
|