@polderlabs/openkan 0.4.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/CHANGELOG.md +226 -0
- package/LICENSE +21 -0
- package/README.md +318 -0
- package/agents/openkan.md +254 -0
- package/bin/install-agent.mjs +63 -0
- package/bin/ok.mjs +17 -0
- package/bin/openkan.mjs +10 -0
- package/dist/.claude/skills/ok-planning/SKILL.md +285 -0
- package/dist/.claude/skills/ok-planning/references/integration.md +153 -0
- package/dist/.claude/skills/ok-planning/references/schemas.md +270 -0
- package/dist/.claude/skills/ok-planning/references/workflows.md +185 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-init.sh +14 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-resume.sh +38 -0
- package/dist/.claude/skills/ok-planning/scripts/ok-status.sh +24 -0
- package/dist/agents/openkan.md +254 -0
- package/dist/bin/install-agent.mjs +76 -0
- package/dist/bin/ok-install.js +58 -0
- package/dist/bin/ok.js +138 -0
- package/dist/bin/openkan.js +804 -0
- package/dist/commands/organize.md +15 -0
- package/dist/kanban/agent-profile.js +8 -0
- package/dist/kanban/archive.js +49 -0
- package/dist/kanban/bizar.js +242 -0
- package/dist/kanban/board.js +367 -0
- package/dist/kanban/bulk.js +139 -0
- package/dist/kanban/changelog.js +186 -0
- package/dist/kanban/chat.js +1280 -0
- package/dist/kanban/claude-state.js +974 -0
- package/dist/kanban/comments.js +80 -0
- package/dist/kanban/docs.js +144 -0
- package/dist/kanban/fs.js +163 -0
- package/dist/kanban/git.js +196 -0
- package/dist/kanban/images.js +140 -0
- package/dist/kanban/import.js +295 -0
- package/dist/kanban/inputs.js +94 -0
- package/dist/kanban/insights.js +140 -0
- package/dist/kanban/io.js +75 -0
- package/dist/kanban/mdx-render.js +348 -0
- package/dist/kanban/mdx.js +231 -0
- package/dist/kanban/projects.js +545 -0
- package/dist/kanban/search.js +121 -0
- package/dist/kanban/server.js +3296 -0
- package/dist/kanban/tags.js +124 -0
- package/dist/kanban/template.js +145 -0
- package/dist/kanban/tsx-sandbox.js +187 -0
- package/dist/kanban/watcher.js +270 -0
- package/dist/ok/commands/goal.js +65 -0
- package/dist/ok/commands/index.js +87 -0
- package/dist/ok/commands/init.js +15 -0
- package/dist/ok/commands/plan.js +155 -0
- package/dist/ok/commands/prd.js +202 -0
- package/dist/ok/commands/progress.js +31 -0
- package/dist/ok/commands/task.js +377 -0
- package/dist/ok/ids.js +98 -0
- package/dist/ok/lock.js +156 -0
- package/dist/ok/migrate.js +197 -0
- package/dist/ok/schemas.js +402 -0
- package/dist/ok/storage.js +222 -0
- package/dist/skills/openkan/SKILL.md +111 -0
- package/dist/skills/openkan/agents/openai.yaml +4 -0
- package/dist/skills/openkan/examples/simple-task.mdx +34 -0
- package/dist/skills/openkan/examples/with-ask.mdx +32 -0
- package/dist/skills/openkan/examples/with-choice.mdx +51 -0
- package/dist/skills/openkan/examples/with-preview.mdx +54 -0
- package/dist/skills/openkan/references/api.md +169 -0
- package/dist/skills/openkan/templates/task.mdx +46 -0
- package/dist/web/api.js +257 -0
- package/dist/web/app.js +4251 -0
- package/dist/web/bizar.js +39 -0
- package/dist/web/brand/agent-activity-sprite.svg +1 -0
- package/dist/web/brand/banner-docs.svg +24 -0
- package/dist/web/brand/banner.svg +32 -0
- package/dist/web/brand/empty-sessions.svg +17 -0
- package/dist/web/brand/empty-tasks.svg +17 -0
- package/dist/web/brand/favicon.svg +9 -0
- package/dist/web/brand/infinity-loader-animated.svg +220 -0
- package/dist/web/brand/infinity-loader-spritesheet.svg +230 -0
- package/dist/web/brand/logo-wordmark.svg +10 -0
- package/dist/web/brand/logo.svg +9 -0
- package/dist/web/brand/pixel-infinity-track.svg +1 -0
- package/dist/web/brand/social-card.svg +26 -0
- package/dist/web/changelog-view.js +456 -0
- package/dist/web/charts.js +269 -0
- package/dist/web/chat-sidebar.js +2397 -0
- package/dist/web/chat-status-motion.js +154 -0
- package/dist/web/claude-pane.js +820 -0
- package/dist/web/command-palette.js +381 -0
- package/dist/web/contributors-view.js +317 -0
- package/dist/web/cross-tab.js +102 -0
- package/dist/web/docs-view.js +168 -0
- package/dist/web/experience.css +165 -0
- package/dist/web/goals-view.js +45 -0
- package/dist/web/home-view.js +113 -0
- package/dist/web/images.js +311 -0
- package/dist/web/index.html +485 -0
- package/dist/web/insights.js +217 -0
- package/dist/web/keyboard.js +446 -0
- package/dist/web/mdx-viewer.js +600 -0
- package/dist/web/path-picker.js +787 -0
- package/dist/web/preview-frame.html +187 -0
- package/dist/web/settings.js +582 -0
- package/dist/web/style.css +8545 -0
- package/dist/web/task-view.js +1759 -0
- package/dist/web/vendor/gsap.min.js +11 -0
- package/dist/web/workspace.css +1513 -0
- package/package.json +71 -0
- package/skills/openkan/SKILL.md +111 -0
- package/skills/openkan/agents/openai.yaml +4 -0
- package/skills/openkan/examples/simple-task.mdx +34 -0
- package/skills/openkan/examples/with-ask.mdx +32 -0
- package/skills/openkan/examples/with-choice.mdx +51 -0
- package/skills/openkan/examples/with-preview.mdx +54 -0
- package/skills/openkan/references/api.md +169 -0
- package/skills/openkan/templates/task.mdx +46 -0
|
@@ -0,0 +1,1280 @@
|
|
|
1
|
+
// OpenKan — chat sidebar backend.
|
|
2
|
+
//
|
|
3
|
+
// This module powers the right-rail chat orchestrator. It owns:
|
|
4
|
+
// 1. JSONL session storage under `.ok/sessions/<sid>.jsonl`
|
|
5
|
+
// 2. A subprocess wrapper that spawns `claude -p --output-format stream-json
|
|
6
|
+
// --verbose` per user turn, parses the line-delimited JSON event stream,
|
|
7
|
+
// and tracks running PIDs in-memory so an abort request can kill the
|
|
8
|
+
// process.
|
|
9
|
+
// 3. An HTTP dispatcher (`handleChatRequest`) registered at `/api/chat/*`.
|
|
10
|
+
//
|
|
11
|
+
// Design choices:
|
|
12
|
+
// - One process per turn (stateless, no daemon). CLI flags `--model`,
|
|
13
|
+
// `--effort`, `--permission-mode` carry the selector values straight
|
|
14
|
+
// through to the Claude Code binary on $PATH (or $CLAUDE_BIN).
|
|
15
|
+
// `--output-format stream-json --verbose` makes Claude Code emit line-
|
|
16
|
+
// delimited JSON events on stdout, which gives the sidebar real-time
|
|
17
|
+
// visibility into tool calls and incremental text.
|
|
18
|
+
// - SSE for live turn events. Two channels:
|
|
19
|
+
// /api/chat/events — every event in the project
|
|
20
|
+
// /api/chat/sessions/<sid>/events — events scoped to a session
|
|
21
|
+
// Both emit typed events (`text_delta`, `tool_use`, `tool_input_delta`,
|
|
22
|
+
// `tool_result`, `message_done`, `chat.turn`).
|
|
23
|
+
// - All persistence is local: `.ok/sessions/` is gitignored already, so
|
|
24
|
+
// user/assistant transcripts never leak into commits.
|
|
25
|
+
import { appendFileSync, existsSync, readdirSync, readFileSync, renameSync, unlinkSync, } from "node:fs";
|
|
26
|
+
import { spawn } from "node:child_process";
|
|
27
|
+
import { join, resolve } from "node:path";
|
|
28
|
+
import { basename } from "node:path";
|
|
29
|
+
import { randomUUID } from "node:crypto";
|
|
30
|
+
import { ensureDir, writeFileAtomic } from "./io.js";
|
|
31
|
+
import { readAgents, readModelRouter } from "./claude-state.js";
|
|
32
|
+
import { OPENKAN_AGENT_ID, openkanAgentDefinition } from "./agent-profile.js";
|
|
33
|
+
// ─── Tool-use label mapper ───────────────────────────────────────────────────
|
|
34
|
+
/** Truncate a string to `max` chars with an ellipsis suffix. */
|
|
35
|
+
function truncate(s, max) {
|
|
36
|
+
if (s.length <= max)
|
|
37
|
+
return s;
|
|
38
|
+
return s.slice(0, Math.max(0, max - 1)) + "…";
|
|
39
|
+
}
|
|
40
|
+
/** Read a possibly-nested string field from an arbitrary input map. */
|
|
41
|
+
function inputString(input, key) {
|
|
42
|
+
const v = input[key];
|
|
43
|
+
return typeof v === "string" ? v : "";
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Map a tool call to a short human-readable label, e.g. `Read`,
|
|
47
|
+
* `Write`, `Bash`. Truncates long arguments so the chip width fits
|
|
48
|
+
* the label without overflowing.
|
|
49
|
+
*/
|
|
50
|
+
export function toolUseLabel(toolUse) {
|
|
51
|
+
const input = toolUse.input ?? {};
|
|
52
|
+
switch (toolUse.name) {
|
|
53
|
+
case "Read":
|
|
54
|
+
return `Reading ${basename(inputString(input, "file_path")) || "file"}`;
|
|
55
|
+
case "Write":
|
|
56
|
+
return `Writing ${basename(inputString(input, "file_path")) || "file"}`;
|
|
57
|
+
case "Edit": {
|
|
58
|
+
const file = inputString(input, "file_path");
|
|
59
|
+
return `Editing ${basename(file) || "file"}`;
|
|
60
|
+
}
|
|
61
|
+
case "Bash":
|
|
62
|
+
return `Running ${truncate(inputString(input, "command").replace(/\s+/g, " ").trim(), 60)}`;
|
|
63
|
+
case "Grep":
|
|
64
|
+
return `Searching for "${truncate(inputString(input, "query") || inputString(input, "pattern"), 40)}"`;
|
|
65
|
+
case "Glob":
|
|
66
|
+
return `Finding ${truncate(inputString(input, "pattern"), 60)}`;
|
|
67
|
+
case "WebFetch":
|
|
68
|
+
return `Fetching ${truncate(inputString(input, "url"), 60)}`;
|
|
69
|
+
case "WebSearch":
|
|
70
|
+
return `Searching the web for "${truncate(inputString(input, "query"), 40)}"`;
|
|
71
|
+
case "Agent":
|
|
72
|
+
case "Task":
|
|
73
|
+
return `Delegating to ${inputString(input, "subagent_type") || "subagent"}`;
|
|
74
|
+
default:
|
|
75
|
+
return `Using ${toolUse.name}`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// ─── Paths ───────────────────────────────────────────────────────────────────
|
|
79
|
+
const SESSIONS_SUBDIR = "sessions";
|
|
80
|
+
const ARCHIVED_SUBDIR = ".archived";
|
|
81
|
+
const SESSION_EXT = ".jsonl";
|
|
82
|
+
/** Resolve the active sessions dir under `<projectRoot>/.ok/sessions`. */
|
|
83
|
+
export function sessionsDir(projectRoot) {
|
|
84
|
+
return join(projectRoot, ".ok", SESSIONS_SUBDIR);
|
|
85
|
+
}
|
|
86
|
+
/** Resolve the archived sessions dir. */
|
|
87
|
+
export function archivedSessionsDir(projectRoot) {
|
|
88
|
+
return join(sessionsDir(projectRoot), ARCHIVED_SUBDIR);
|
|
89
|
+
}
|
|
90
|
+
function sessionPath(projectRoot, sessionId) {
|
|
91
|
+
return join(sessionsDir(projectRoot), `${sessionId}${SESSION_EXT}`);
|
|
92
|
+
}
|
|
93
|
+
function archivedSessionPath(projectRoot, sessionId) {
|
|
94
|
+
return join(archivedSessionsDir(projectRoot), `${sessionId}${SESSION_EXT}`);
|
|
95
|
+
}
|
|
96
|
+
function ensureSessionsDirs(projectRoot) {
|
|
97
|
+
ensureDir(sessionsDir(projectRoot));
|
|
98
|
+
ensureDir(archivedSessionsDir(projectRoot));
|
|
99
|
+
}
|
|
100
|
+
// ─── ID helpers ──────────────────────────────────────────────────────────────
|
|
101
|
+
/** Generate a unique session id (`ses-<uuid>`). */
|
|
102
|
+
export function generateSessionId() {
|
|
103
|
+
return `ses-${randomUUID()}`;
|
|
104
|
+
}
|
|
105
|
+
function newMessageId() {
|
|
106
|
+
return `msg-${randomUUID()}`;
|
|
107
|
+
}
|
|
108
|
+
function nowIso() {
|
|
109
|
+
return new Date().toISOString();
|
|
110
|
+
}
|
|
111
|
+
// ─── JSONL parsing ───────────────────────────────────────────────────────────
|
|
112
|
+
/**
|
|
113
|
+
* Parse a JSONL file into an array of turns. Empty lines and unparseable
|
|
114
|
+
* rows are silently skipped (matches `changelog.ts:parseLine` behaviour but
|
|
115
|
+
* without the stderr warning — chat history is rebuilt incrementally and
|
|
116
|
+
* one bad row should not break a session).
|
|
117
|
+
*/
|
|
118
|
+
function parseJsonl(raw) {
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const line of raw.split("\n")) {
|
|
121
|
+
const trimmed = line.trim();
|
|
122
|
+
if (!trimmed)
|
|
123
|
+
continue;
|
|
124
|
+
try {
|
|
125
|
+
const obj = JSON.parse(trimmed);
|
|
126
|
+
if (obj && typeof obj === "object" && typeof obj.role === "string") {
|
|
127
|
+
// Backwards-compat: legacy turns were written before `toolUses`
|
|
128
|
+
// existed. Normalise absent → empty array so downstream code can
|
|
129
|
+
// safely iterate without checking for `undefined`.
|
|
130
|
+
if (obj.toolUses === undefined)
|
|
131
|
+
obj.toolUses = [];
|
|
132
|
+
out.push(obj);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
/* skip unparseable row */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
function readTurnsFile(path) {
|
|
142
|
+
if (!existsSync(path))
|
|
143
|
+
return [];
|
|
144
|
+
try {
|
|
145
|
+
return parseJsonl(readFileSync(path, "utf-8"));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// ─── Storage ─────────────────────────────────────────────────────────────────
|
|
152
|
+
/** Append a turn to the session JSONL file. Atomic on first write. */
|
|
153
|
+
export function appendTurn(projectRoot, sessionId, turn) {
|
|
154
|
+
ensureSessionsDirs(projectRoot);
|
|
155
|
+
const path = sessionPath(projectRoot, sessionId);
|
|
156
|
+
const line = JSON.stringify(turn) + "\n";
|
|
157
|
+
if (!existsSync(path)) {
|
|
158
|
+
writeFileAtomic(path, line);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
appendFileSync(path, line, "utf-8");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** Read every turn for a session, looking in active and archived dirs. */
|
|
165
|
+
export function readSession(projectRoot, sessionId) {
|
|
166
|
+
const active = sessionPath(projectRoot, sessionId);
|
|
167
|
+
if (existsSync(active))
|
|
168
|
+
return readTurnsFile(active);
|
|
169
|
+
const archived = archivedSessionPath(projectRoot, sessionId);
|
|
170
|
+
if (existsSync(archived))
|
|
171
|
+
return readTurnsFile(archived);
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
/** True when the session is in the active dir. */
|
|
175
|
+
export function isSessionActive(projectRoot, sessionId) {
|
|
176
|
+
return existsSync(sessionPath(projectRoot, sessionId));
|
|
177
|
+
}
|
|
178
|
+
/** True when the session has been archived. */
|
|
179
|
+
export function isSessionArchived(projectRoot, sessionId) {
|
|
180
|
+
return existsSync(archivedSessionPath(projectRoot, sessionId));
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Derive a session summary from the transcript. The title is the first user
|
|
184
|
+
* message truncated to 80 chars.
|
|
185
|
+
*/
|
|
186
|
+
export function summariseSession(sessionId, turns, archived) {
|
|
187
|
+
const firstUser = turns.find((t) => t.role === "user");
|
|
188
|
+
const last = turns[turns.length - 1];
|
|
189
|
+
const titleRaw = firstUser?.content?.trim() ?? "(empty session)";
|
|
190
|
+
const title = titleRaw.length > 80 ? titleRaw.slice(0, 79) + "…" : titleRaw;
|
|
191
|
+
const selectorTurn = turns.find((t) => t.role === "assistant" && t.model) ?? firstUser;
|
|
192
|
+
return {
|
|
193
|
+
id: sessionId,
|
|
194
|
+
title,
|
|
195
|
+
model: selectorTurn?.model ?? null,
|
|
196
|
+
effort: selectorTurn?.effort ?? null,
|
|
197
|
+
permissionMode: selectorTurn?.permissionMode ?? null,
|
|
198
|
+
createdAt: turns[0]?.ts ?? nowIso(),
|
|
199
|
+
lastActivity: last?.ts ?? turns[0]?.ts ?? nowIso(),
|
|
200
|
+
turnCount: turns.length,
|
|
201
|
+
archived,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/** List every session (active first, then archived) in last-activity order. */
|
|
205
|
+
export function listSessions(projectRoot) {
|
|
206
|
+
const summaries = [];
|
|
207
|
+
if (!existsSync(sessionsDir(projectRoot)))
|
|
208
|
+
return summaries;
|
|
209
|
+
const activeEntries = readdirSync(sessionsDir(projectRoot))
|
|
210
|
+
.filter((f) => f.endsWith(SESSION_EXT));
|
|
211
|
+
for (const file of activeEntries) {
|
|
212
|
+
const id = file.slice(0, -SESSION_EXT.length);
|
|
213
|
+
if (id.startsWith("."))
|
|
214
|
+
continue; // skip hidden (e.g. .archived marker)
|
|
215
|
+
const turns = readTurnsFile(sessionPath(projectRoot, id));
|
|
216
|
+
summaries.push(summariseSession(id, turns, false));
|
|
217
|
+
}
|
|
218
|
+
const archivedDir = archivedSessionsDir(projectRoot);
|
|
219
|
+
if (existsSync(archivedDir)) {
|
|
220
|
+
const archivedEntries = readdirSync(archivedDir)
|
|
221
|
+
.filter((f) => f.endsWith(SESSION_EXT));
|
|
222
|
+
for (const file of archivedEntries) {
|
|
223
|
+
const id = file.slice(0, -SESSION_EXT.length);
|
|
224
|
+
if (id.startsWith("."))
|
|
225
|
+
continue;
|
|
226
|
+
const turns = readTurnsFile(archivedSessionPath(projectRoot, id));
|
|
227
|
+
summaries.push(summariseSession(id, turns, true));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Sort by lastActivity desc. Active before archived ties are not guaranteed
|
|
231
|
+
// (the caller can filter); we sort by timestamp only and rely on the UI to
|
|
232
|
+
// group them.
|
|
233
|
+
summaries.sort((a, b) => (a.lastActivity < b.lastActivity ? 1 : -1));
|
|
234
|
+
return summaries;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Archive an active session by moving its JSONL file to `.archived/`.
|
|
238
|
+
* Returns true on success, false if the session was not active.
|
|
239
|
+
*/
|
|
240
|
+
export function archiveSession(projectRoot, sessionId) {
|
|
241
|
+
const active = sessionPath(projectRoot, sessionId);
|
|
242
|
+
if (!existsSync(active))
|
|
243
|
+
return false;
|
|
244
|
+
ensureSessionsDirs(projectRoot);
|
|
245
|
+
const dest = archivedSessionPath(projectRoot, sessionId);
|
|
246
|
+
// renameSync overwrites the destination on POSIX; on Windows it would fail,
|
|
247
|
+
// but OpenKan targets Linux/macOS as the primary platforms.
|
|
248
|
+
renameSync(active, dest);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Permanently remove a session (active or archived). Used by tests; the
|
|
253
|
+
* public API uses `archiveSession` for the user-facing DELETE action.
|
|
254
|
+
*/
|
|
255
|
+
export function deleteSession(projectRoot, sessionId) {
|
|
256
|
+
const active = sessionPath(projectRoot, sessionId);
|
|
257
|
+
const archived = archivedSessionPath(projectRoot, sessionId);
|
|
258
|
+
let removed = false;
|
|
259
|
+
if (existsSync(active)) {
|
|
260
|
+
try {
|
|
261
|
+
unlinkSync(active);
|
|
262
|
+
removed = true;
|
|
263
|
+
}
|
|
264
|
+
catch { /* ignore */ }
|
|
265
|
+
}
|
|
266
|
+
if (existsSync(archived)) {
|
|
267
|
+
try {
|
|
268
|
+
unlinkSync(archived);
|
|
269
|
+
removed = true;
|
|
270
|
+
}
|
|
271
|
+
catch { /* ignore */ }
|
|
272
|
+
}
|
|
273
|
+
return removed;
|
|
274
|
+
}
|
|
275
|
+
// ─── Subprocess wrapper ──────────────────────────────────────────────────────
|
|
276
|
+
/** Validate a chat selector set. Throws on invalid input. */
|
|
277
|
+
export function validateSelectors(selectors) {
|
|
278
|
+
if (selectors.agent !== undefined && (typeof selectors.agent !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_:./-]{0,127}$/.test(selectors.agent)))
|
|
279
|
+
throw new Error("Invalid agent identifier");
|
|
280
|
+
const eff = selectors.effort;
|
|
281
|
+
const validEffort = new Set(["low", "medium", "high", "max"]);
|
|
282
|
+
if (!validEffort.has(eff)) {
|
|
283
|
+
throw new Error(`Invalid effort: ${eff}. Expected one of ${[...validEffort].join(", ")}.`);
|
|
284
|
+
}
|
|
285
|
+
const validPerm = new Set(ALLOWED_PERMISSION_MODES);
|
|
286
|
+
if (!validPerm.has(selectors.permissionMode)) {
|
|
287
|
+
throw new Error(`Invalid permissionMode: ${selectors.permissionMode}. Expected one of ${[...validPerm].join(", ")}.`);
|
|
288
|
+
}
|
|
289
|
+
if (!selectors.model || typeof selectors.model !== "string") {
|
|
290
|
+
throw new Error("model is required");
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/** Allowed permission modes for the Claude Code CLI. */
|
|
294
|
+
export const ALLOWED_PERMISSION_MODES = [
|
|
295
|
+
"acceptEdits", "auto", "bypassPermissions", "manual", "dontAsk", "plan",
|
|
296
|
+
];
|
|
297
|
+
/**
|
|
298
|
+
* Derive a picker-style option list (id + label) from a model id. Strips a
|
|
299
|
+
* `provider/` prefix when present so `minimax/MiniMax-M3` displays as
|
|
300
|
+
* `MiniMax-M3` in the UI. Used by both the picker endpoint and tests.
|
|
301
|
+
*/
|
|
302
|
+
export function toPickerLabel(id) {
|
|
303
|
+
const slash = id.indexOf("/");
|
|
304
|
+
return slash >= 0 ? id.slice(slash + 1) : id;
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Build the picker options payload for `/api/chat/picker-options`. Pulls the
|
|
308
|
+
* model list from `readModelRouter` so the chat UI and the model's routing
|
|
309
|
+
* policy stay in sync without duplicating I/O. Tests can inject a model
|
|
310
|
+
* list via `overrides.models` to avoid filesystem fixture setup.
|
|
311
|
+
*/
|
|
312
|
+
export async function pickerOptions(projectRoot, overrides = {}) {
|
|
313
|
+
const router = await readModelRouter(projectRoot);
|
|
314
|
+
// Older router files often specify policy defaults but omit the optional
|
|
315
|
+
// `models` array. Expose those configured model ids in the picker rather
|
|
316
|
+
// than leaving the model menu apparently empty.
|
|
317
|
+
const policyModels = Object.values(router.policies ?? {})
|
|
318
|
+
.filter((value) => typeof value === "string" && value.length > 0);
|
|
319
|
+
const ids = overrides.models ?? [...router.models, ...policyModels];
|
|
320
|
+
const models = [];
|
|
321
|
+
const seen = new Set();
|
|
322
|
+
for (const id of ids) {
|
|
323
|
+
if (typeof id !== "string" || !id || seen.has(id))
|
|
324
|
+
continue;
|
|
325
|
+
seen.add(id);
|
|
326
|
+
models.push({ id, label: toPickerLabel(id) });
|
|
327
|
+
}
|
|
328
|
+
const profiles = await readAgents(projectRoot);
|
|
329
|
+
const agents = [
|
|
330
|
+
{ id: OPENKAN_AGENT_ID, label: "OpenKan", description: "Planning, structure, goals, and project management" },
|
|
331
|
+
{ id: "default", label: "Claude Code", description: "General-purpose assistant without a custom agent profile" },
|
|
332
|
+
...profiles.filter(profile => profile.id !== OPENKAN_AGENT_ID && profile.id !== "default").map(profile => ({ id: profile.id, label: profile.id, description: String(profile.frontmatter.description || "Custom Claude agent") })),
|
|
333
|
+
];
|
|
334
|
+
return {
|
|
335
|
+
agents,
|
|
336
|
+
models,
|
|
337
|
+
efforts: ALLOWED_EFFORT_LEVELS,
|
|
338
|
+
permissionModes: ALLOWED_PERMISSION_MODES,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/** Allowed effort levels for the Claude Code CLI. */
|
|
342
|
+
export const ALLOWED_EFFORT_LEVELS = ["low", "medium", "high", "max"];
|
|
343
|
+
/** Resolve which Claude binary to invoke. Honours $CLAUDE_BIN. */
|
|
344
|
+
export function resolveClaudeBin(override) {
|
|
345
|
+
if (override && override.trim())
|
|
346
|
+
return resolve(override);
|
|
347
|
+
if (process.env.CLAUDE_BIN && process.env.CLAUDE_BIN.trim()) {
|
|
348
|
+
return resolve(process.env.CLAUDE_BIN);
|
|
349
|
+
}
|
|
350
|
+
return "claude";
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Process and live-event identities include the project root. Session IDs are
|
|
354
|
+
* random, but making the scope explicit prevents a project switch from ever
|
|
355
|
+
* sharing an abort target or an SSE event channel with another workspace.
|
|
356
|
+
*/
|
|
357
|
+
function projectScope(projectRoot) {
|
|
358
|
+
return resolve(projectRoot);
|
|
359
|
+
}
|
|
360
|
+
function scopedSessionKey(projectRoot, sessionId) {
|
|
361
|
+
return `${projectScope(projectRoot)}\u0000${sessionId}`;
|
|
362
|
+
}
|
|
363
|
+
/** Per-project/session child-process registry. */
|
|
364
|
+
const runningProcs = new Map();
|
|
365
|
+
/** Inspect the running process registry (used by tests and the API layer). */
|
|
366
|
+
export function listRunningSessions(projectRoot) {
|
|
367
|
+
const scope = projectRoot ? projectScope(projectRoot) : null;
|
|
368
|
+
return [...runningProcs.values()]
|
|
369
|
+
.filter((entry) => !scope || entry.projectRoot === scope)
|
|
370
|
+
.map((entry) => entry.sessionId);
|
|
371
|
+
}
|
|
372
|
+
function registerProc(projectRoot, sessionId, child) {
|
|
373
|
+
const root = projectScope(projectRoot);
|
|
374
|
+
const key = scopedSessionKey(root, sessionId);
|
|
375
|
+
const existing = runningProcs.get(key)?.child;
|
|
376
|
+
if (existing && !existing.killed) {
|
|
377
|
+
try {
|
|
378
|
+
existing.kill("SIGTERM");
|
|
379
|
+
}
|
|
380
|
+
catch { /* ignore */ }
|
|
381
|
+
}
|
|
382
|
+
runningProcs.set(key, { projectRoot: root, sessionId, child });
|
|
383
|
+
}
|
|
384
|
+
function clearProc(projectRoot, sessionId, child) {
|
|
385
|
+
const key = scopedSessionKey(projectRoot, sessionId);
|
|
386
|
+
if (runningProcs.get(key)?.child === child)
|
|
387
|
+
runningProcs.delete(key);
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Kill the subprocess for a project/session pair. The single-argument form is
|
|
391
|
+
* retained for test/backward compatibility; HTTP calls always pass a project.
|
|
392
|
+
*/
|
|
393
|
+
export function abortSession(projectRootOrSessionId, maybeSessionId) {
|
|
394
|
+
const entry = maybeSessionId
|
|
395
|
+
? runningProcs.get(scopedSessionKey(projectRootOrSessionId, maybeSessionId))
|
|
396
|
+
: [...runningProcs.values()].find((candidate) => candidate.sessionId === projectRootOrSessionId);
|
|
397
|
+
if (!entry)
|
|
398
|
+
return false;
|
|
399
|
+
const { child } = entry;
|
|
400
|
+
try {
|
|
401
|
+
child.kill("SIGTERM");
|
|
402
|
+
}
|
|
403
|
+
catch { /* ignore */ }
|
|
404
|
+
// Force-kill after a grace period if it has not exited.
|
|
405
|
+
setTimeout(() => {
|
|
406
|
+
const key = scopedSessionKey(entry.projectRoot, entry.sessionId);
|
|
407
|
+
if (runningProcs.get(key)?.child === child) {
|
|
408
|
+
try {
|
|
409
|
+
child.kill("SIGKILL");
|
|
410
|
+
}
|
|
411
|
+
catch { /* ignore */ }
|
|
412
|
+
}
|
|
413
|
+
}, 2000).unref?.();
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
const TURN_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
417
|
+
// ─── Stream parser ───────────────────────────────────────────────────────────
|
|
418
|
+
/**
|
|
419
|
+
* Parse one NDJSON line into a typed StreamEvent. Returns null when the
|
|
420
|
+
* line is empty / not an object — caller should treat that as "no event
|
|
421
|
+
* for this chunk" and continue. Exported so unit tests can exercise the
|
|
422
|
+
* parser without spawning a child process.
|
|
423
|
+
*/
|
|
424
|
+
export function parseStreamLine(line) {
|
|
425
|
+
const trimmed = line.trim();
|
|
426
|
+
if (!trimmed)
|
|
427
|
+
return null;
|
|
428
|
+
let raw;
|
|
429
|
+
try {
|
|
430
|
+
const parsed = JSON.parse(trimmed);
|
|
431
|
+
if (!parsed || typeof parsed !== "object")
|
|
432
|
+
return null;
|
|
433
|
+
raw = parsed;
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
// `--include-partial-messages` wraps Anthropic-style deltas as
|
|
439
|
+
// `{type:"stream_event",event:{...}}`. Normalise that documented envelope
|
|
440
|
+
// before parsing, while retaining its original payload for diagnostics.
|
|
441
|
+
const envelope = raw;
|
|
442
|
+
if (raw.type === "stream_event" && raw.event && typeof raw.event === "object") {
|
|
443
|
+
raw = raw.event;
|
|
444
|
+
}
|
|
445
|
+
let type = typeof raw.type === "string" ? raw.type : "unknown";
|
|
446
|
+
const parentFrom = (value) => {
|
|
447
|
+
if (!value || typeof value !== "object")
|
|
448
|
+
return undefined;
|
|
449
|
+
const record = value;
|
|
450
|
+
const id = record.parent_tool_use_id ?? record.parentToolUseId;
|
|
451
|
+
return typeof id === "string" && id.length > 0 ? id : undefined;
|
|
452
|
+
};
|
|
453
|
+
const event = { type, raw: { ...raw, _envelope: envelope.type === "stream_event" ? envelope : undefined } };
|
|
454
|
+
// `--forward-subagent-text` annotates forwarded assistant/user messages
|
|
455
|
+
// with this id. Claude Code has shipped it both on the outer record and on
|
|
456
|
+
// `message`, so accept either documented shape (and the partial envelope).
|
|
457
|
+
event.parentToolUseId = parentFrom(raw)
|
|
458
|
+
?? parentFrom(raw.message)
|
|
459
|
+
?? parentFrom(envelope)
|
|
460
|
+
?? parentFrom(envelope.message);
|
|
461
|
+
if (typeof raw.index === "number")
|
|
462
|
+
event.index = raw.index;
|
|
463
|
+
// Claude Code's current stream-json format emits completed assistant
|
|
464
|
+
// snapshots (`{type:"assistant", message:{content:[...]}}`) instead of
|
|
465
|
+
// Anthropic content_block_delta records. Normalise visible text into the
|
|
466
|
+
// same delta shape so the UI and persisted turn never go blank.
|
|
467
|
+
if (type === "assistant") {
|
|
468
|
+
const content = raw.message?.content;
|
|
469
|
+
const text = Array.isArray(content)
|
|
470
|
+
? content.filter((part) => part && typeof part === "object" && part.type === "text")
|
|
471
|
+
.map((part) => String(part.text ?? "")).join("")
|
|
472
|
+
: "";
|
|
473
|
+
if (text) {
|
|
474
|
+
type = "content_block_delta";
|
|
475
|
+
event.type = type;
|
|
476
|
+
event.index = 0;
|
|
477
|
+
event.delta = { type: "text_delta", text };
|
|
478
|
+
return event;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
// The terminal result carries a reliable text fallback for providers that
|
|
482
|
+
// do not emit a separate assistant-text record.
|
|
483
|
+
if (type === "result" && typeof raw.result === "string" && raw.result) {
|
|
484
|
+
event.type = "content_block_delta";
|
|
485
|
+
event.index = 0;
|
|
486
|
+
event.delta = { type: "text_delta", text: raw.result };
|
|
487
|
+
return event;
|
|
488
|
+
}
|
|
489
|
+
// content_block_start carries the block descriptor under `content_block`.
|
|
490
|
+
const cb = raw.content_block;
|
|
491
|
+
if (cb && typeof cb === "object") {
|
|
492
|
+
const block = cb;
|
|
493
|
+
const blockType = typeof block.type === "string" ? block.type : "";
|
|
494
|
+
if (blockType === "text" || blockType === "thinking" || blockType === "tool_use" || blockType === "tool_result") {
|
|
495
|
+
event.contentBlock = {
|
|
496
|
+
type: blockType,
|
|
497
|
+
id: typeof block.id === "string" ? block.id : undefined,
|
|
498
|
+
name: typeof block.name === "string" ? block.name : undefined,
|
|
499
|
+
tool_use_id: typeof block.tool_use_id === "string" ? block.tool_use_id : undefined,
|
|
500
|
+
input: block.input && typeof block.input === "object"
|
|
501
|
+
? block.input
|
|
502
|
+
: undefined,
|
|
503
|
+
content: typeof block.content === "string"
|
|
504
|
+
? block.content
|
|
505
|
+
: Array.isArray(block.content)
|
|
506
|
+
? block.content
|
|
507
|
+
: undefined,
|
|
508
|
+
text: typeof block.text === "string" ? block.text : undefined,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// content_block_delta / message_delta carry the delta under `delta`.
|
|
513
|
+
const d = raw.delta;
|
|
514
|
+
if (d && typeof d === "object") {
|
|
515
|
+
const delta = d;
|
|
516
|
+
event.delta = {
|
|
517
|
+
type: typeof delta.type === "string" ? delta.type : "",
|
|
518
|
+
text: typeof delta.text === "string" ? delta.text : undefined,
|
|
519
|
+
thinking: typeof delta.thinking === "string" ? delta.thinking : undefined,
|
|
520
|
+
partial_json: typeof delta.partial_json === "string" ? delta.partial_json : undefined,
|
|
521
|
+
stop_reason: typeof delta.stop_reason === "string" ? delta.stop_reason : undefined,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
return event;
|
|
525
|
+
}
|
|
526
|
+
/** Apply a parsed stream event to the in-memory TurnState. Returns the
|
|
527
|
+
* list of chip-friendly events (tool_use, tool_result, text_delta) that
|
|
528
|
+
* were produced, so callers can fan them out over SSE without re-parsing.
|
|
529
|
+
* Exported for unit tests.
|
|
530
|
+
*/
|
|
531
|
+
function nextToolSequence(state) {
|
|
532
|
+
state.toolSequence = (state.toolSequence ?? 0) + 1;
|
|
533
|
+
return state.toolSequence;
|
|
534
|
+
}
|
|
535
|
+
/** Preserve subagent file/tool work in the final parent activity audit while
|
|
536
|
+
* deliberately excluding forwarded text and thinking from the parent reply. */
|
|
537
|
+
function applyForwardedToolEvent(state, event) {
|
|
538
|
+
const parentToolUseId = event.parentToolUseId;
|
|
539
|
+
const block = event.contentBlock;
|
|
540
|
+
if (!parentToolUseId || !block)
|
|
541
|
+
return;
|
|
542
|
+
const tools = state.subagentToolUses ??= new Map();
|
|
543
|
+
if (block.type === "tool_use" && block.id) {
|
|
544
|
+
const key = `${parentToolUseId}:${block.id}`;
|
|
545
|
+
tools.set(key, {
|
|
546
|
+
id: `subagent:${key}`,
|
|
547
|
+
name: block.name ?? "unknown",
|
|
548
|
+
input: block.input ?? {},
|
|
549
|
+
status: "started",
|
|
550
|
+
source: "subagent",
|
|
551
|
+
parentToolUseId,
|
|
552
|
+
sequence: nextToolSequence(state),
|
|
553
|
+
});
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (block.type !== "tool_result" || !block.tool_use_id)
|
|
557
|
+
return;
|
|
558
|
+
const existing = tools.get(`${parentToolUseId}:${block.tool_use_id}`);
|
|
559
|
+
if (!existing)
|
|
560
|
+
return;
|
|
561
|
+
const text = typeof block.content === "string"
|
|
562
|
+
? block.content
|
|
563
|
+
: Array.isArray(block.content)
|
|
564
|
+
? block.content.map((item) => item.text ?? "").join("")
|
|
565
|
+
: "";
|
|
566
|
+
const rawBlock = event.raw.content_block;
|
|
567
|
+
existing.status = rawBlock?.is_error ? "failed" : "completed";
|
|
568
|
+
existing.resultPreview = text.slice(0, 200);
|
|
569
|
+
existing.isError = Boolean(rawBlock?.is_error);
|
|
570
|
+
tools.set(`${parentToolUseId}:${block.tool_use_id}`, existing);
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Merge a text event without replaying Claude's assistant snapshots after
|
|
574
|
+
* incremental stream deltas. The return value is exactly the new suffix that
|
|
575
|
+
* belongs in the live SSE transcript.
|
|
576
|
+
*/
|
|
577
|
+
function mergeText(state, index, text, snapshot) {
|
|
578
|
+
const previous = state.textByBlock.get(index) ?? "";
|
|
579
|
+
if (!snapshot) {
|
|
580
|
+
state.textByBlock.set(index, previous + text);
|
|
581
|
+
return text;
|
|
582
|
+
}
|
|
583
|
+
if (!previous) {
|
|
584
|
+
state.textByBlock.set(index, text);
|
|
585
|
+
return text;
|
|
586
|
+
}
|
|
587
|
+
if (previous === text || previous.endsWith(text) || previous.startsWith(text))
|
|
588
|
+
return "";
|
|
589
|
+
if (text.startsWith(previous)) {
|
|
590
|
+
state.textByBlock.set(index, text);
|
|
591
|
+
return text.slice(previous.length);
|
|
592
|
+
}
|
|
593
|
+
// An assistant snapshot should supersede a partial stream rather than
|
|
594
|
+
// duplicate it. It is intentionally not emitted as a second live delta.
|
|
595
|
+
state.textByBlock.set(index, text);
|
|
596
|
+
return "";
|
|
597
|
+
}
|
|
598
|
+
export function applyStreamEvent(state, event) {
|
|
599
|
+
// Forwarded child text and thinking stay out of the parent reply, while
|
|
600
|
+
// their real tool effects remain available in the completed audit trail.
|
|
601
|
+
if (event.parentToolUseId) {
|
|
602
|
+
applyForwardedToolEvent(state, event);
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
// `result` is a fallback for providers that skip assistant text events.
|
|
606
|
+
// Do not append it when a normal assistant snapshot has already supplied text.
|
|
607
|
+
if (event.raw.type === "result" && [...state.textByBlock.values()].some(Boolean))
|
|
608
|
+
return null;
|
|
609
|
+
switch (event.type) {
|
|
610
|
+
case "content_block_start": {
|
|
611
|
+
const cb = event.contentBlock;
|
|
612
|
+
if (!cb)
|
|
613
|
+
return null;
|
|
614
|
+
if (cb.type === "text" && typeof event.index === "number") {
|
|
615
|
+
state.textByBlock.set(event.index, "");
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
if (cb.type === "thinking" && typeof event.index === "number") {
|
|
619
|
+
state.reasoningByBlock.set(event.index, cb.text ?? "");
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
if (cb.type === "tool_use" && typeof event.index === "number" && cb.id) {
|
|
623
|
+
const toolUse = {
|
|
624
|
+
id: cb.id,
|
|
625
|
+
name: cb.name ?? "unknown",
|
|
626
|
+
input: cb.input ?? {},
|
|
627
|
+
status: "started",
|
|
628
|
+
sequence: nextToolSequence(state),
|
|
629
|
+
};
|
|
630
|
+
state.toolUses.set(event.index, toolUse);
|
|
631
|
+
state.toolIndexById.set(cb.id, event.index);
|
|
632
|
+
return { toolUseIndex: event.index, toolUse };
|
|
633
|
+
}
|
|
634
|
+
if (cb.type === "tool_result" && cb.tool_use_id) {
|
|
635
|
+
const text = typeof cb.content === "string"
|
|
636
|
+
? cb.content
|
|
637
|
+
: Array.isArray(cb.content)
|
|
638
|
+
? cb.content.map((c) => c.text ?? "").join("")
|
|
639
|
+
: "";
|
|
640
|
+
const isError = Boolean(event.raw.content_block?.is_error);
|
|
641
|
+
state.toolResults.set(cb.tool_use_id, { content: text, isError });
|
|
642
|
+
const idx = state.toolIndexById.get(cb.tool_use_id);
|
|
643
|
+
if (idx !== undefined) {
|
|
644
|
+
const existing = state.toolUses.get(idx);
|
|
645
|
+
if (existing) {
|
|
646
|
+
existing.status = isError ? "failed" : "completed";
|
|
647
|
+
existing.resultPreview = text.slice(0, 200);
|
|
648
|
+
existing.isError = isError;
|
|
649
|
+
state.toolUses.set(idx, existing);
|
|
650
|
+
return { toolResult: { ...existing } };
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
case "content_block_delta": {
|
|
657
|
+
const d = event.delta;
|
|
658
|
+
if (!d)
|
|
659
|
+
return null;
|
|
660
|
+
if (d.type === "text_delta" && typeof event.index === "number") {
|
|
661
|
+
const text = mergeText(state, event.index, d.text ?? "", event.raw.type === "assistant");
|
|
662
|
+
return { textDelta: text };
|
|
663
|
+
}
|
|
664
|
+
if ((d.type === "thinking_delta" || d.type === "reasoning_delta") && typeof event.index === "number") {
|
|
665
|
+
const text = d.thinking ?? d.text ?? "";
|
|
666
|
+
state.reasoningByBlock.set(event.index, (state.reasoningByBlock.get(event.index) ?? "") + text);
|
|
667
|
+
return { reasoningDelta: text };
|
|
668
|
+
}
|
|
669
|
+
if (d.type === "input_json_delta" && typeof event.index === "number") {
|
|
670
|
+
const existing = state.toolUses.get(event.index);
|
|
671
|
+
if (existing) {
|
|
672
|
+
existing.status = "streaming";
|
|
673
|
+
state.toolUses.set(event.index, existing);
|
|
674
|
+
}
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
case "message_delta": {
|
|
680
|
+
const reason = event.delta?.stop_reason;
|
|
681
|
+
if (typeof reason === "string") {
|
|
682
|
+
state.stopReason = reason;
|
|
683
|
+
return { stopReason: reason };
|
|
684
|
+
}
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
case "message_stop": {
|
|
688
|
+
// Mark any started/streaming tool calls that never received a result
|
|
689
|
+
// as aborted (the upstream was cut short).
|
|
690
|
+
for (const [, tool] of state.toolUses) {
|
|
691
|
+
if (tool.status === "started" || tool.status === "streaming") {
|
|
692
|
+
tool.status = "aborted";
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return null;
|
|
696
|
+
}
|
|
697
|
+
default:
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
/** Assemble the persisted assistant turn from a finished TurnState. */
|
|
702
|
+
export function assembleAssistantTurn(state, opts, assistantTs, status = "ok", error) {
|
|
703
|
+
// Concatenate text blocks in index order.
|
|
704
|
+
const orderedIndexes = [...state.textByBlock.keys()].sort((a, b) => a - b);
|
|
705
|
+
const content = orderedIndexes.map((i) => state.textByBlock.get(i) ?? "").join("");
|
|
706
|
+
const reasoning = [...state.reasoningByBlock.keys()].sort((a, b) => a - b)
|
|
707
|
+
.map((i) => state.reasoningByBlock.get(i) ?? "").join("");
|
|
708
|
+
// Concatenate tool uses in the order they were opened (index order).
|
|
709
|
+
const toolUses = [
|
|
710
|
+
...[...state.toolUses.entries()].sort((a, b) => a[0] - b[0]).map(([, tool]) => tool),
|
|
711
|
+
...[...(state.subagentToolUses ?? new Map()).values()],
|
|
712
|
+
].sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
|
|
713
|
+
const turn = {
|
|
714
|
+
ts: assistantTs,
|
|
715
|
+
role: status === "ok" ? "assistant" : "system",
|
|
716
|
+
content,
|
|
717
|
+
agent: opts.agent ?? OPENKAN_AGENT_ID,
|
|
718
|
+
model: opts.model,
|
|
719
|
+
effort: opts.effort,
|
|
720
|
+
permissionMode: opts.permissionMode,
|
|
721
|
+
messageId: newMessageId(),
|
|
722
|
+
status: status ?? "ok",
|
|
723
|
+
toolUses,
|
|
724
|
+
};
|
|
725
|
+
if (reasoning)
|
|
726
|
+
turn.reasoning = reasoning;
|
|
727
|
+
if (error)
|
|
728
|
+
turn.error = error;
|
|
729
|
+
return turn;
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Spawn `claude -p "<message>" --output-format stream-json --verbose` and
|
|
733
|
+
* stream parsed NDJSON events back via the provided callback. Returns once
|
|
734
|
+
* the subprocess exits and the final assistant turn has been persisted.
|
|
735
|
+
*/
|
|
736
|
+
export async function sendTurn(projectRoot, opts) {
|
|
737
|
+
validateSelectors(opts);
|
|
738
|
+
const sessionId = opts.sessionId ?? generateSessionId();
|
|
739
|
+
const messageId = newMessageId();
|
|
740
|
+
const startedAtMs = Date.now();
|
|
741
|
+
const ts = nowIso();
|
|
742
|
+
const userTurn = {
|
|
743
|
+
ts,
|
|
744
|
+
role: "user",
|
|
745
|
+
content: opts.displayMessage ?? opts.message,
|
|
746
|
+
taskMentions: opts.taskMentions,
|
|
747
|
+
agent: opts.agent ?? OPENKAN_AGENT_ID,
|
|
748
|
+
model: opts.model,
|
|
749
|
+
effort: opts.effort,
|
|
750
|
+
permissionMode: opts.permissionMode,
|
|
751
|
+
messageId,
|
|
752
|
+
};
|
|
753
|
+
appendTurn(projectRoot, sessionId, userTurn);
|
|
754
|
+
const bin = resolveClaudeBin(opts.claudeBin);
|
|
755
|
+
const args = [
|
|
756
|
+
"-p",
|
|
757
|
+
opts.message,
|
|
758
|
+
"--model", opts.model,
|
|
759
|
+
"--effort", opts.effort,
|
|
760
|
+
"--permission-mode", opts.permissionMode,
|
|
761
|
+
"--output-format", "stream-json",
|
|
762
|
+
"--verbose",
|
|
763
|
+
"--include-partial-messages",
|
|
764
|
+
"--include-hook-events",
|
|
765
|
+
"--forward-subagent-text",
|
|
766
|
+
];
|
|
767
|
+
const selectedAgent = opts.agent ?? OPENKAN_AGENT_ID;
|
|
768
|
+
if (selectedAgent !== "default") {
|
|
769
|
+
// The bundled default also works with --ignore-scripts or a read-only home.
|
|
770
|
+
// Existing user/project profiles retain their normal Claude precedence.
|
|
771
|
+
if (selectedAgent === OPENKAN_AGENT_ID && !(await readAgents(projectRoot)).some(profile => profile.id === OPENKAN_AGENT_ID)) {
|
|
772
|
+
args.push("--agents", JSON.stringify({ [OPENKAN_AGENT_ID]: openkanAgentDefinition() }));
|
|
773
|
+
}
|
|
774
|
+
args.push("--agent", selectedAgent);
|
|
775
|
+
}
|
|
776
|
+
const child = spawn(bin, args, {
|
|
777
|
+
cwd: opts.cwd ?? projectRoot,
|
|
778
|
+
env: opts.env ?? process.env,
|
|
779
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
780
|
+
});
|
|
781
|
+
registerProc(projectRoot, sessionId, child);
|
|
782
|
+
const abortHandler = () => {
|
|
783
|
+
try {
|
|
784
|
+
child.kill("SIGTERM");
|
|
785
|
+
}
|
|
786
|
+
catch { /* ignore */ }
|
|
787
|
+
};
|
|
788
|
+
if (opts.signal) {
|
|
789
|
+
if (opts.signal.aborted)
|
|
790
|
+
abortHandler();
|
|
791
|
+
else
|
|
792
|
+
opts.signal.addEventListener("abort", abortHandler, { once: true });
|
|
793
|
+
}
|
|
794
|
+
let stderrBuf = "";
|
|
795
|
+
let timedOut = false;
|
|
796
|
+
const timeout = setTimeout(() => {
|
|
797
|
+
timedOut = true;
|
|
798
|
+
try {
|
|
799
|
+
child.kill("SIGTERM");
|
|
800
|
+
}
|
|
801
|
+
catch { /* ignore */ }
|
|
802
|
+
}, TURN_TIMEOUT_MS);
|
|
803
|
+
timeout.unref?.();
|
|
804
|
+
// Line-buffer for NDJSON. Chunks from stdout are appended to `lineBuf`
|
|
805
|
+
// and split on `\n` so we never lose a partial line straddling chunks.
|
|
806
|
+
let lineBuf = "";
|
|
807
|
+
const state = {
|
|
808
|
+
textByBlock: new Map(),
|
|
809
|
+
reasoningByBlock: new Map(),
|
|
810
|
+
toolUses: new Map(),
|
|
811
|
+
toolIndexById: new Map(),
|
|
812
|
+
toolResults: new Map(),
|
|
813
|
+
subagentToolUses: new Map(),
|
|
814
|
+
toolSequence: 0,
|
|
815
|
+
stopReason: null,
|
|
816
|
+
};
|
|
817
|
+
// Child-exit promise. We capture both `code` and `signal` because aborts
|
|
818
|
+
// via SIGTERM/SIGKILL exit with code `null` and a signal name.
|
|
819
|
+
const exitInfo = await new Promise((resolveP, rejectP) => {
|
|
820
|
+
child.stdout?.on("data", (chunk) => {
|
|
821
|
+
const text = chunk.toString("utf-8");
|
|
822
|
+
lineBuf += text;
|
|
823
|
+
let nl;
|
|
824
|
+
while ((nl = lineBuf.indexOf("\n")) !== -1) {
|
|
825
|
+
const line = lineBuf.slice(0, nl);
|
|
826
|
+
lineBuf = lineBuf.slice(nl + 1);
|
|
827
|
+
const event = parseStreamLine(line);
|
|
828
|
+
if (!event)
|
|
829
|
+
continue;
|
|
830
|
+
const applied = applyStreamEvent(state, event);
|
|
831
|
+
opts.onStreamEvent?.(event, applied);
|
|
832
|
+
}
|
|
833
|
+
});
|
|
834
|
+
child.stderr?.on("data", (chunk) => {
|
|
835
|
+
stderrBuf += chunk.toString("utf-8");
|
|
836
|
+
});
|
|
837
|
+
child.on("error", (err) => rejectP(err));
|
|
838
|
+
child.on("exit", (code, signal) => resolveP({ code, signal }));
|
|
839
|
+
});
|
|
840
|
+
// Drain any trailing line that did not end in \n (e.g. partial write).
|
|
841
|
+
if (lineBuf.trim()) {
|
|
842
|
+
const event = parseStreamLine(lineBuf);
|
|
843
|
+
if (event) {
|
|
844
|
+
const applied = applyStreamEvent(state, event);
|
|
845
|
+
opts.onStreamEvent?.(event, applied);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
const exitCode = exitInfo.code;
|
|
849
|
+
const exitSignal = exitInfo.signal;
|
|
850
|
+
clearTimeout(timeout);
|
|
851
|
+
if (opts.signal)
|
|
852
|
+
opts.signal.removeEventListener("abort", abortHandler);
|
|
853
|
+
clearProc(projectRoot, sessionId, child);
|
|
854
|
+
const assistantTs = nowIso();
|
|
855
|
+
const killedBySignal = exitCode === null && exitSignal !== null;
|
|
856
|
+
const aborted = opts.signal?.aborted || timedOut || killedBySignal;
|
|
857
|
+
let assistantTurn;
|
|
858
|
+
if (aborted) {
|
|
859
|
+
const assembled = assembleAssistantTurn(state, opts, assistantTs, "aborted");
|
|
860
|
+
assembled.role = "system";
|
|
861
|
+
if (!assembled.content)
|
|
862
|
+
assembled.content = "(assistant turn aborted before completion)";
|
|
863
|
+
assistantTurn = assembled;
|
|
864
|
+
}
|
|
865
|
+
else if (exitCode !== 0) {
|
|
866
|
+
assistantTurn = assembleAssistantTurn(state, opts, assistantTs, "error", stderrBuf.trim() || `exit code ${exitCode}`);
|
|
867
|
+
assistantTurn.role = "system";
|
|
868
|
+
if (!assistantTurn.content) {
|
|
869
|
+
assistantTurn.content = stderrBuf.trim() || `claude exited with code ${exitCode}`;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
else {
|
|
873
|
+
assistantTurn = assembleAssistantTurn(state, opts, assistantTs, "ok");
|
|
874
|
+
}
|
|
875
|
+
assistantTurn.durationMs = Date.now() - startedAtMs;
|
|
876
|
+
appendTurn(projectRoot, sessionId, assistantTurn);
|
|
877
|
+
return { sessionId, userTurn, assistantTurn };
|
|
878
|
+
}
|
|
879
|
+
// ─── HTTP dispatcher ─────────────────────────────────────────────────────────
|
|
880
|
+
const SSE_HEADERS = {
|
|
881
|
+
"Content-Type": "text/event-stream",
|
|
882
|
+
"Cache-Control": "no-cache",
|
|
883
|
+
"Connection": "keep-alive",
|
|
884
|
+
"X-Accel-Buffering": "no",
|
|
885
|
+
};
|
|
886
|
+
/** Project-scoped SSE channels; switching workspaces must never fan out activity across projects. */
|
|
887
|
+
const chatSseControllers = new Map();
|
|
888
|
+
/** Per-project/session SSE channel. */
|
|
889
|
+
const sessionChatSseControllers = new Map();
|
|
890
|
+
function controllersForProject(projectRoot) {
|
|
891
|
+
const scope = projectScope(projectRoot);
|
|
892
|
+
let controllers = chatSseControllers.get(scope);
|
|
893
|
+
if (!controllers) {
|
|
894
|
+
controllers = new Set();
|
|
895
|
+
chatSseControllers.set(scope, controllers);
|
|
896
|
+
}
|
|
897
|
+
return controllers;
|
|
898
|
+
}
|
|
899
|
+
function broadcastChat(projectRoot, event, data) {
|
|
900
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
901
|
+
for (const ctrl of chatSseControllers.get(projectScope(projectRoot)) ?? []) {
|
|
902
|
+
try {
|
|
903
|
+
ctrl.enqueue(new TextEncoder().encode(payload));
|
|
904
|
+
}
|
|
905
|
+
catch { /* ignore */ }
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
/** Push an event only to subscribers of this project/session pair. */
|
|
909
|
+
function broadcastChatSession(projectRoot, sessionId, event, data) {
|
|
910
|
+
const ctrls = sessionChatSseControllers.get(scopedSessionKey(projectRoot, sessionId));
|
|
911
|
+
if (!ctrls)
|
|
912
|
+
return;
|
|
913
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
914
|
+
for (const ctrl of ctrls) {
|
|
915
|
+
try {
|
|
916
|
+
ctrl.enqueue(new TextEncoder().encode(payload));
|
|
917
|
+
}
|
|
918
|
+
catch { /* ignore */ }
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
function isHighSignalActivity(event) {
|
|
922
|
+
const raw = event.raw;
|
|
923
|
+
const hook = raw.hook_event_name ?? raw.hookEventName;
|
|
924
|
+
const subtype = typeof raw.subtype === "string" ? raw.subtype.toLowerCase() : "";
|
|
925
|
+
if (event.parentToolUseId && event.contentBlock?.type === "tool_use")
|
|
926
|
+
return true;
|
|
927
|
+
if (hook || raw.mcp_server_name || raw.mcp_tool_name)
|
|
928
|
+
return true;
|
|
929
|
+
if (subtype.includes("retry") || subtype.includes("team") || subtype.includes("workflow") || subtype.includes("agent"))
|
|
930
|
+
return true;
|
|
931
|
+
return event.type === "error" || raw.type === "error";
|
|
932
|
+
}
|
|
933
|
+
function jsonResponse(body, status = 200) {
|
|
934
|
+
return new Response(JSON.stringify(body), {
|
|
935
|
+
status,
|
|
936
|
+
headers: { "Content-Type": "application/json" },
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
function errResponse(message, status = 400) {
|
|
940
|
+
return jsonResponse({ error: message }, status);
|
|
941
|
+
}
|
|
942
|
+
const ALLOWED_TAGS = [
|
|
943
|
+
"p", "br", "strong", "em", "del", "code", "pre",
|
|
944
|
+
"ul", "ol", "li", "blockquote",
|
|
945
|
+
"h1", "h2", "h3", "h4", "h5", "h6",
|
|
946
|
+
"table", "thead", "tbody", "tr", "th", "td",
|
|
947
|
+
"a", "hr", "img", "span", "div",
|
|
948
|
+
];
|
|
949
|
+
const ALLOWED_ATTRS = {
|
|
950
|
+
a: ["href", "title"],
|
|
951
|
+
code: ["class"],
|
|
952
|
+
img: ["src", "alt", "title"],
|
|
953
|
+
};
|
|
954
|
+
/**
|
|
955
|
+
* Server-side markdown rendering for chat messages. Mirrors the
|
|
956
|
+
* `renderArtifact` pipeline in `server.ts` so the chat sidebar can show
|
|
957
|
+
* sanitised HTML without bundling `marked` into the browser.
|
|
958
|
+
*/
|
|
959
|
+
async function renderMarkdown(raw) {
|
|
960
|
+
const [{ marked }, sanitizeHtmlMod] = await Promise.all([
|
|
961
|
+
import("marked"),
|
|
962
|
+
import("sanitize-html"),
|
|
963
|
+
]);
|
|
964
|
+
const html = await marked(raw);
|
|
965
|
+
const sanitizeHtml = sanitizeHtmlMod.default;
|
|
966
|
+
return sanitizeHtml(html, {
|
|
967
|
+
allowedTags: ALLOWED_TAGS,
|
|
968
|
+
allowedAttributes: ALLOWED_ATTRS,
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
async function readJsonBody(req) {
|
|
972
|
+
try {
|
|
973
|
+
const body = await req.json();
|
|
974
|
+
if (body && typeof body === "object")
|
|
975
|
+
return body;
|
|
976
|
+
return null;
|
|
977
|
+
}
|
|
978
|
+
catch {
|
|
979
|
+
return null;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* HTTP dispatcher for `/api/chat/*`. Mirrors the shape of `handleBizarRequest`
|
|
984
|
+
* / `handleClaudeRequest`: returns a `Response` for every request.
|
|
985
|
+
*/
|
|
986
|
+
export async function handleChatRequest(projectRoot, req, path) {
|
|
987
|
+
void new URL(req.url); // reserved for future query filters; suppress lint
|
|
988
|
+
try {
|
|
989
|
+
// GET /api/chat/sessions — list every session summary
|
|
990
|
+
if (req.method === "GET" && path === "/api/chat/sessions") {
|
|
991
|
+
return jsonResponse({ sessions: listSessions(projectRoot) });
|
|
992
|
+
}
|
|
993
|
+
// GET /api/chat/sessions/:sid — full transcript
|
|
994
|
+
const getOne = path.match(/^\/api\/chat\/sessions\/([^/]+)$/);
|
|
995
|
+
if (getOne && req.method === "GET") {
|
|
996
|
+
const sid = decodeURIComponent(getOne[1]);
|
|
997
|
+
const turns = readSession(projectRoot, sid);
|
|
998
|
+
if (turns.length === 0) {
|
|
999
|
+
return errResponse(`Session not found: ${sid}`, 404);
|
|
1000
|
+
}
|
|
1001
|
+
const archived = isSessionArchived(projectRoot, sid);
|
|
1002
|
+
return jsonResponse({
|
|
1003
|
+
session: summariseSession(sid, turns, archived),
|
|
1004
|
+
running: listRunningSessions(projectRoot).includes(sid),
|
|
1005
|
+
turns,
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
// DELETE /api/chat/sessions/:sid — archive
|
|
1009
|
+
const delOne = path.match(/^\/api\/chat\/sessions\/([^/]+)$/);
|
|
1010
|
+
if (delOne && req.method === "DELETE") {
|
|
1011
|
+
const sid = decodeURIComponent(delOne[1]);
|
|
1012
|
+
if (!isSessionActive(projectRoot, sid) && !isSessionArchived(projectRoot, sid)) {
|
|
1013
|
+
return errResponse(`Session not found: ${sid}`, 404);
|
|
1014
|
+
}
|
|
1015
|
+
// If active, move to archived; if already archived, hard-delete.
|
|
1016
|
+
if (isSessionActive(projectRoot, sid)) {
|
|
1017
|
+
archiveSession(projectRoot, sid);
|
|
1018
|
+
}
|
|
1019
|
+
else {
|
|
1020
|
+
deleteSession(projectRoot, sid);
|
|
1021
|
+
}
|
|
1022
|
+
broadcastChat(projectRoot, "chat.session-archived", { sessionId: sid });
|
|
1023
|
+
return jsonResponse({ ok: true, sessionId: sid, archived: true });
|
|
1024
|
+
}
|
|
1025
|
+
// POST /api/chat/sessions/:sid/abort — kill running subprocess
|
|
1026
|
+
const abortMatch = path.match(/^\/api\/chat\/sessions\/([^/]+)\/abort$/);
|
|
1027
|
+
if (abortMatch && req.method === "POST") {
|
|
1028
|
+
const sid = decodeURIComponent(abortMatch[1]);
|
|
1029
|
+
const killed = abortSession(projectRoot, sid);
|
|
1030
|
+
return jsonResponse({ ok: true, sessionId: sid, killed });
|
|
1031
|
+
}
|
|
1032
|
+
// GET /api/chat/sessions/:sid/events — SSE stream scoped to one session
|
|
1033
|
+
const eventsMatch = path.match(/^\/api\/chat\/sessions\/([^/]+)\/events$/);
|
|
1034
|
+
if (eventsMatch && req.method === "GET") {
|
|
1035
|
+
const sid = decodeURIComponent(eventsMatch[1]);
|
|
1036
|
+
const encoder = new TextEncoder();
|
|
1037
|
+
const stream = new ReadableStream({
|
|
1038
|
+
start(ctrl) {
|
|
1039
|
+
// Subscribe to BOTH the global and per-session channel so the
|
|
1040
|
+
// sidebar receives `chat.turn` rollups and per-event tool/text
|
|
1041
|
+
// updates on the same stream.
|
|
1042
|
+
controllersForProject(projectRoot).add(ctrl);
|
|
1043
|
+
const sessionKey = scopedSessionKey(projectRoot, sid);
|
|
1044
|
+
let set = sessionChatSseControllers.get(sessionKey);
|
|
1045
|
+
if (!set) {
|
|
1046
|
+
set = new Set();
|
|
1047
|
+
sessionChatSseControllers.set(sessionKey, set);
|
|
1048
|
+
}
|
|
1049
|
+
set.add(ctrl);
|
|
1050
|
+
ctrl.enqueue(encoder.encode(`event: chat.session-connected\ndata: ${JSON.stringify({ sessionId: sid })}\n\n`));
|
|
1051
|
+
},
|
|
1052
|
+
cancel() {
|
|
1053
|
+
const controllers = chatSseControllers.get(projectScope(projectRoot));
|
|
1054
|
+
controllers?.delete(this);
|
|
1055
|
+
const sessionKey = scopedSessionKey(projectRoot, sid);
|
|
1056
|
+
const set = sessionChatSseControllers.get(sessionKey);
|
|
1057
|
+
if (set) {
|
|
1058
|
+
for (const c of set) {
|
|
1059
|
+
if (c === this)
|
|
1060
|
+
set.delete(c);
|
|
1061
|
+
}
|
|
1062
|
+
if (set.size === 0)
|
|
1063
|
+
sessionChatSseControllers.delete(sessionKey);
|
|
1064
|
+
}
|
|
1065
|
+
},
|
|
1066
|
+
});
|
|
1067
|
+
return new Response(stream, { headers: SSE_HEADERS });
|
|
1068
|
+
}
|
|
1069
|
+
// POST /api/chat/send — send a new turn
|
|
1070
|
+
if (req.method === "POST" && path === "/api/chat/send") {
|
|
1071
|
+
const body = await readJsonBody(req);
|
|
1072
|
+
if (!body)
|
|
1073
|
+
return errResponse("Invalid JSON body", 400);
|
|
1074
|
+
const message = typeof body.message === "string" ? body.message.trim() : "";
|
|
1075
|
+
const taskMentions = Array.isArray(body.taskMentions)
|
|
1076
|
+
? body.taskMentions
|
|
1077
|
+
.filter((item) => !!item && typeof item === "object")
|
|
1078
|
+
.map((item) => ({
|
|
1079
|
+
id: typeof item.id === "string" ? item.id.trim() : "",
|
|
1080
|
+
title: typeof item.title === "string" ? item.title.trim() : "",
|
|
1081
|
+
}))
|
|
1082
|
+
.filter((item) => item.id.length > 0)
|
|
1083
|
+
.slice(0, 12)
|
|
1084
|
+
: [];
|
|
1085
|
+
if (!message && taskMentions.length === 0)
|
|
1086
|
+
return errResponse("message or task reference is required", 422);
|
|
1087
|
+
const taskContext = taskMentions.length
|
|
1088
|
+
? `\n\nReferenced OpenKan tasks (use these as project context):\n${taskMentions.map((task) => `- ${task.id}: ${task.title || "Untitled task"}`).join("\n")}`
|
|
1089
|
+
: "";
|
|
1090
|
+
const agentMessage = `${message || "Please help with the referenced task."}${taskContext}`;
|
|
1091
|
+
const legacyPermissionModes = {
|
|
1092
|
+
"accept-edits": "acceptEdits", default: "auto", "bypass-permissions": "bypassPermissions",
|
|
1093
|
+
};
|
|
1094
|
+
const requestedPermissionMode = typeof body.permissionMode === "string" && body.permissionMode
|
|
1095
|
+
? body.permissionMode : "bypassPermissions";
|
|
1096
|
+
const selectors = {
|
|
1097
|
+
agent: body.agent === undefined ? OPENKAN_AGENT_ID : typeof body.agent === "string" ? body.agent : "",
|
|
1098
|
+
model: typeof body.model === "string" && body.model ? body.model : "default",
|
|
1099
|
+
effort: typeof body.effort === "string" && body.effort ? body.effort : "high",
|
|
1100
|
+
permissionMode: legacyPermissionModes[requestedPermissionMode] ?? requestedPermissionMode,
|
|
1101
|
+
};
|
|
1102
|
+
try {
|
|
1103
|
+
validateSelectors(selectors);
|
|
1104
|
+
if (selectors.agent !== OPENKAN_AGENT_ID && selectors.agent !== "default" && !(await readAgents(projectRoot)).some(profile => profile.id === selectors.agent))
|
|
1105
|
+
throw new Error("Selected agent is unavailable. Choose an installed agent.");
|
|
1106
|
+
}
|
|
1107
|
+
catch (e) {
|
|
1108
|
+
return errResponse(e.message, 422);
|
|
1109
|
+
}
|
|
1110
|
+
const sessionId = typeof body.sessionId === "string" && body.sessionId
|
|
1111
|
+
? body.sessionId
|
|
1112
|
+
: generateSessionId();
|
|
1113
|
+
try {
|
|
1114
|
+
// Start immediately and return an acknowledgement. The browser can now
|
|
1115
|
+
// subscribe to this session before Claude's first streamed event,
|
|
1116
|
+
// rather than staring at an empty composer until the process exits.
|
|
1117
|
+
broadcastChatSession(projectRoot, sessionId, "chat.status", { sessionId, phase: "thinking", label: "Thinking" });
|
|
1118
|
+
const running = sendTurn(projectRoot, {
|
|
1119
|
+
sessionId,
|
|
1120
|
+
message: agentMessage,
|
|
1121
|
+
displayMessage: message,
|
|
1122
|
+
taskMentions,
|
|
1123
|
+
agent: selectors.agent,
|
|
1124
|
+
model: selectors.model,
|
|
1125
|
+
effort: selectors.effort,
|
|
1126
|
+
permissionMode: selectors.permissionMode,
|
|
1127
|
+
onStreamEvent: (event, applied) => {
|
|
1128
|
+
// Live activity remains intentionally quiet: lifecycle changes and
|
|
1129
|
+
// the current subagent tool are useful while work is running;
|
|
1130
|
+
// individual thinking/text tokens are retained nowhere in the UI.
|
|
1131
|
+
if (isHighSignalActivity(event)) {
|
|
1132
|
+
broadcastChatSession(projectRoot, sessionId, "chat.activity", {
|
|
1133
|
+
sessionId,
|
|
1134
|
+
type: event.type,
|
|
1135
|
+
subtype: typeof event.raw.subtype === "string" ? event.raw.subtype : undefined,
|
|
1136
|
+
parentToolUseId: event.parentToolUseId,
|
|
1137
|
+
raw: event.raw,
|
|
1138
|
+
ts: nowIso(),
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
// Subagent output belongs to the nested activity tree. Never
|
|
1142
|
+
// stream it into the parent assistant bubble.
|
|
1143
|
+
if (event.parentToolUseId)
|
|
1144
|
+
return;
|
|
1145
|
+
// Per-session channel: stream typed events for chips + bubbles.
|
|
1146
|
+
switch (event.type) {
|
|
1147
|
+
case "content_block_start": {
|
|
1148
|
+
const cb = event.contentBlock;
|
|
1149
|
+
if (cb?.type === "tool_use" && cb.id && event.index !== undefined) {
|
|
1150
|
+
broadcastChatSession(projectRoot, sessionId, "chat.tool-use", {
|
|
1151
|
+
sessionId,
|
|
1152
|
+
id: cb.id,
|
|
1153
|
+
name: cb.name ?? "unknown",
|
|
1154
|
+
input: cb.input ?? {},
|
|
1155
|
+
status: "started",
|
|
1156
|
+
index: event.index,
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
break;
|
|
1160
|
+
}
|
|
1161
|
+
case "content_block_delta": {
|
|
1162
|
+
const d = event.delta;
|
|
1163
|
+
if (d?.type === "text_delta" && applied && "textDelta" in applied && applied.textDelta) {
|
|
1164
|
+
broadcastChatSession(projectRoot, sessionId, "chat.text-delta", {
|
|
1165
|
+
sessionId,
|
|
1166
|
+
text: applied.textDelta,
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
else if ((d?.type === "thinking_delta" || d?.type === "reasoning_delta") && typeof (d.thinking ?? d.text) === "string") {
|
|
1170
|
+
broadcastChatSession(projectRoot, sessionId, "chat.reasoning-delta", {
|
|
1171
|
+
sessionId,
|
|
1172
|
+
text: d.thinking ?? d.text,
|
|
1173
|
+
});
|
|
1174
|
+
}
|
|
1175
|
+
else if (d?.type === "input_json_delta" && typeof event.index === "number") {
|
|
1176
|
+
broadcastChatSession(projectRoot, sessionId, "chat.tool-input-delta", {
|
|
1177
|
+
sessionId,
|
|
1178
|
+
index: event.index,
|
|
1179
|
+
partialJson: d.partial_json ?? "",
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
break;
|
|
1183
|
+
}
|
|
1184
|
+
case "message_delta": {
|
|
1185
|
+
if (event.delta?.stop_reason) {
|
|
1186
|
+
broadcastChatSession(projectRoot, sessionId, "chat.message-delta", {
|
|
1187
|
+
sessionId,
|
|
1188
|
+
stopReason: event.delta.stop_reason,
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
break;
|
|
1192
|
+
}
|
|
1193
|
+
case "message_stop": {
|
|
1194
|
+
broadcastChatSession(projectRoot, sessionId, "chat.message-done", {
|
|
1195
|
+
sessionId,
|
|
1196
|
+
stopReason: event.delta?.stop_reason ?? null,
|
|
1197
|
+
});
|
|
1198
|
+
break;
|
|
1199
|
+
}
|
|
1200
|
+
default:
|
|
1201
|
+
break;
|
|
1202
|
+
}
|
|
1203
|
+
},
|
|
1204
|
+
});
|
|
1205
|
+
void running.then((result) => {
|
|
1206
|
+
broadcastChat(projectRoot, "chat.turn", { sessionId: result.sessionId, userTurn: result.userTurn, assistantTurn: result.assistantTurn });
|
|
1207
|
+
broadcastChatSession(projectRoot, result.sessionId, "chat.turn", { sessionId: result.sessionId, userTurn: result.userTurn, assistantTurn: result.assistantTurn });
|
|
1208
|
+
}).catch((error) => {
|
|
1209
|
+
broadcastChatSession(projectRoot, sessionId, "chat.status", { sessionId, phase: "error", label: "Chat failed", error: error instanceof Error ? error.message : String(error) });
|
|
1210
|
+
});
|
|
1211
|
+
const userTurn = readSession(projectRoot, sessionId).at(-1);
|
|
1212
|
+
return jsonResponse({ sessionId, accepted: true, userTurn }, 202);
|
|
1213
|
+
}
|
|
1214
|
+
catch (e) {
|
|
1215
|
+
const message = e?.message || "sendTurn failed";
|
|
1216
|
+
return errResponse(message, 500);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
// GET /api/chat/events — SSE stream of every chat event in the project
|
|
1220
|
+
if (req.method === "GET" && path === "/api/chat/events") {
|
|
1221
|
+
const encoder = new TextEncoder();
|
|
1222
|
+
const stream = new ReadableStream({
|
|
1223
|
+
start(ctrl) {
|
|
1224
|
+
controllersForProject(projectRoot).add(ctrl);
|
|
1225
|
+
ctrl.enqueue(encoder.encode("event: chat.connected\ndata: {}\n\n"));
|
|
1226
|
+
},
|
|
1227
|
+
cancel(ctrl) {
|
|
1228
|
+
const scope = projectScope(projectRoot);
|
|
1229
|
+
const controllers = chatSseControllers.get(scope);
|
|
1230
|
+
controllers?.delete(ctrl);
|
|
1231
|
+
if (controllers?.size === 0)
|
|
1232
|
+
chatSseControllers.delete(scope);
|
|
1233
|
+
},
|
|
1234
|
+
});
|
|
1235
|
+
return new Response(stream, { headers: SSE_HEADERS });
|
|
1236
|
+
}
|
|
1237
|
+
// GET /api/chat/selectors — server-side allowed values for the UI
|
|
1238
|
+
if (req.method === "GET" && path === "/api/chat/selectors") {
|
|
1239
|
+
return jsonResponse({
|
|
1240
|
+
efforts: ALLOWED_EFFORT_LEVELS,
|
|
1241
|
+
permissionModes: ALLOWED_PERMISSION_MODES,
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
// GET /api/chat/picker-options — model list (sourced from the project
|
|
1245
|
+
// model-router) + allowed effort + permission modes. Used by the chat
|
|
1246
|
+
// sidebar's model pill popover.
|
|
1247
|
+
if (req.method === "GET" && path === "/api/chat/picker-options") {
|
|
1248
|
+
return jsonResponse(await pickerOptions(projectRoot));
|
|
1249
|
+
}
|
|
1250
|
+
// POST /api/chat/render-markdown — sanitised HTML for chat messages
|
|
1251
|
+
if (req.method === "POST" && path === "/api/chat/render-markdown") {
|
|
1252
|
+
const body = await readJsonBody(req);
|
|
1253
|
+
if (!body)
|
|
1254
|
+
return errResponse("Invalid JSON body", 400);
|
|
1255
|
+
const md = typeof body.markdown === "string" ? body.markdown : "";
|
|
1256
|
+
const html = await renderMarkdown(md);
|
|
1257
|
+
return new Response(html, {
|
|
1258
|
+
status: 200,
|
|
1259
|
+
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
return errResponse("Not found", 404);
|
|
1263
|
+
}
|
|
1264
|
+
catch (e) {
|
|
1265
|
+
const message = e?.message || String(e);
|
|
1266
|
+
return errResponse(message, 500);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
// Test-only export so tests can wipe the registry between runs.
|
|
1270
|
+
export function _resetRunningProcsForTests() {
|
|
1271
|
+
for (const { child } of runningProcs.values()) {
|
|
1272
|
+
try {
|
|
1273
|
+
child.kill("SIGKILL");
|
|
1274
|
+
}
|
|
1275
|
+
catch { /* ignore */ }
|
|
1276
|
+
}
|
|
1277
|
+
runningProcs.clear();
|
|
1278
|
+
chatSseControllers.clear();
|
|
1279
|
+
sessionChatSseControllers.clear();
|
|
1280
|
+
}
|