@somewhatintelligent/cc-ws-client 0.1.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 +16 -0
- package/package.json +24 -0
- package/src/controls.ts +95 -0
- package/src/index.ts +109 -0
- package/src/messages.ts +312 -0
- package/src/modes.ts +68 -0
- package/src/permissions.ts +105 -0
- package/src/persistence.ts +121 -0
- package/src/protocol.ts +525 -0
- package/src/session.ts +662 -0
- package/src/shell.ts +272 -0
- package/src/tasks.ts +246 -0
- package/src/usage.ts +30 -0
- package/src/ws.ts +98 -0
package/src/shell.ts
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Bash exchange XML — single source of truth for the format the bridge
|
|
2
|
+
// replays back when a `bash_command` frame runs. Three shapes ride the
|
|
3
|
+
// `user` content channel:
|
|
4
|
+
// input-only <bash-input>cmd</bash-input>
|
|
5
|
+
// output-only <bash-stdout>…</bash-stdout><bash-stderr>…</bash-stderr><bash-exit-code>0</bash-exit-code>
|
|
6
|
+
// merged both, plus arbitrary trailing user text (drain-synthesis)
|
|
7
|
+
// Both the lib's outbound writer (handleShellReplay) and any UI that
|
|
8
|
+
// renders bash exchanges parse the same shape — keep them in lockstep
|
|
9
|
+
// here.
|
|
10
|
+
|
|
11
|
+
import { atom, type WritableAtom } from "nanostores";
|
|
12
|
+
import { isSystemFrame, isUserFrame, type InboundFrame } from "./protocol";
|
|
13
|
+
import type { WsClient } from "./ws";
|
|
14
|
+
|
|
15
|
+
export function escapeXml(s: string): string {
|
|
16
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function decodeXml(s: string): string {
|
|
20
|
+
return s.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const RE_INPUT = /<bash-input>([\s\S]*?)<\/bash-input>/;
|
|
24
|
+
const RE_STDOUT = /<bash-stdout>([\s\S]*?)<\/bash-stdout>/;
|
|
25
|
+
const RE_STDERR = /<bash-stderr>([\s\S]*?)<\/bash-stderr>/;
|
|
26
|
+
const RE_EXIT = /<bash-exit-code>([\s\S]*?)<\/bash-exit-code>/;
|
|
27
|
+
|
|
28
|
+
export type BashFrame =
|
|
29
|
+
| { kind: "input"; command: string }
|
|
30
|
+
| { kind: "output"; stdout: string; stderr: string; exit: string }
|
|
31
|
+
| { kind: "merged"; command: string; stdout: string; stderr: string; exit: string; trailing: string };
|
|
32
|
+
|
|
33
|
+
export function parseBashFrame(text: string): BashFrame | null {
|
|
34
|
+
const inMatch = text.match(RE_INPUT);
|
|
35
|
+
const outMatch = text.match(RE_STDOUT);
|
|
36
|
+
const errMatch = text.match(RE_STDERR);
|
|
37
|
+
const exitMatch = text.match(RE_EXIT);
|
|
38
|
+
const hasInput = inMatch != null;
|
|
39
|
+
const hasOutput = outMatch != null || errMatch != null || exitMatch != null;
|
|
40
|
+
if (!hasInput && !hasOutput) return null;
|
|
41
|
+
|
|
42
|
+
if (hasInput && hasOutput) {
|
|
43
|
+
return {
|
|
44
|
+
kind: "merged",
|
|
45
|
+
command: decodeXml(inMatch![1]!),
|
|
46
|
+
stdout: outMatch ? decodeXml(outMatch[1]!) : "",
|
|
47
|
+
stderr: errMatch ? decodeXml(errMatch[1]!) : "",
|
|
48
|
+
exit: exitMatch ? decodeXml(exitMatch[1]!) : "",
|
|
49
|
+
trailing: text
|
|
50
|
+
.replace(RE_INPUT, "")
|
|
51
|
+
.replace(RE_STDOUT, "")
|
|
52
|
+
.replace(RE_STDERR, "")
|
|
53
|
+
.replace(RE_EXIT, "")
|
|
54
|
+
.trim(),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (hasInput) {
|
|
58
|
+
return { kind: "input", command: decodeXml(inMatch![1]!) };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
kind: "output",
|
|
62
|
+
stdout: outMatch ? decodeXml(outMatch[1]!) : "",
|
|
63
|
+
stderr: errMatch ? decodeXml(errMatch[1]!) : "",
|
|
64
|
+
exit: exitMatch ? decodeXml(exitMatch[1]!) : "",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Reconstruct the merged-frame XML the lib drains into the next user
|
|
69
|
+
// message. Encoding mirrors what the binary sends back so the round-trip
|
|
70
|
+
// is lossless.
|
|
71
|
+
export function buildBashXml(command: string, stdoutXml: string, stderrXml: string): string {
|
|
72
|
+
return (
|
|
73
|
+
`<bash-input>${escapeXml(command)}</bash-input>\n` +
|
|
74
|
+
`<bash-stdout>${stdoutXml}</bash-stdout>` +
|
|
75
|
+
`<bash-stderr>${stderrXml}</bash-stderr>`
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------- runtime controller ----------
|
|
80
|
+
|
|
81
|
+
export type ShellSource = "context" | "sideChannel";
|
|
82
|
+
|
|
83
|
+
export type ShellEntry = {
|
|
84
|
+
id: string;
|
|
85
|
+
command: string;
|
|
86
|
+
source: ShellSource;
|
|
87
|
+
chunks: string[];
|
|
88
|
+
// For context-shell: the exchange has run on the bridge but the
|
|
89
|
+
// <bash-input>/<bash-stdout> XML hasn't been sent to claude yet — it
|
|
90
|
+
// gets prepended to the user's next sendMessage() (matches the TUI's
|
|
91
|
+
// shouldQuery:false flow). Cleared when drained.
|
|
92
|
+
pending?: boolean;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export type ShellController = {
|
|
96
|
+
shellEntries: WritableAtom<ShellEntry[]>;
|
|
97
|
+
// Frame ingestion side-effects (return false intentionally for the
|
|
98
|
+
// user-replay path — see handleShellReplay below).
|
|
99
|
+
handleShellReplay: (frame: InboundFrame) => boolean;
|
|
100
|
+
handleLocalCommandOutput: (frame: InboundFrame) => boolean;
|
|
101
|
+
// Public ops.
|
|
102
|
+
sendShellContext: (command: string, followUp?: string) => void;
|
|
103
|
+
sendBashSideChannel: (command: string) => void;
|
|
104
|
+
dismissShellEntry: (id: string) => void;
|
|
105
|
+
// Drain pending bash exchanges into outbound text + clear their entries.
|
|
106
|
+
// Returns the rewritten payload for sendMessage to wire-send.
|
|
107
|
+
drainPending: (text: string) => string;
|
|
108
|
+
hasPending: () => boolean;
|
|
109
|
+
reset: () => void;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export function createShellController(args: {
|
|
113
|
+
ws: WsClient;
|
|
114
|
+
// sendMessage is back-injected because a queued follow-up text fires
|
|
115
|
+
// sendMessage(followUp) after the bash exchange's XML is buffered, and
|
|
116
|
+
// sendMessage in turn drains via drainPending().
|
|
117
|
+
sendMessage: (text: string) => void;
|
|
118
|
+
}): ShellController {
|
|
119
|
+
const { ws, sendMessage } = args;
|
|
120
|
+
const shellEntries = atom<ShellEntry[]>([]);
|
|
121
|
+
|
|
122
|
+
// bash_command replay frames don't carry a correlation id, but the
|
|
123
|
+
// binary processes them in the order received and replies in the same
|
|
124
|
+
// order. So we keep a FIFO of in-flight captures: each call to
|
|
125
|
+
// sendShellContext / sendBashSideChannel pushes; each output-replay
|
|
126
|
+
// frame shifts. Two bash sends in quick succession used to clobber
|
|
127
|
+
// each other when we tracked a single `activeShellId` global.
|
|
128
|
+
const captureQueue: {
|
|
129
|
+
entryId: string;
|
|
130
|
+
command: string;
|
|
131
|
+
source: ShellSource;
|
|
132
|
+
sawInputEcho: boolean;
|
|
133
|
+
}[] = [];
|
|
134
|
+
// Buffered <bash-input>/<bash-stdout>/<bash-stderr> XML, FIFO. Filled
|
|
135
|
+
// when a context-shell capture's output replay arrives; drained by
|
|
136
|
+
// sendMessage() which prepends to the user's next prompt. The TUI-`!cmd`
|
|
137
|
+
// parity workaround: bash_command's replay frames are NOT injected
|
|
138
|
+
// into claude's transcript by the binary (verified empirically).
|
|
139
|
+
const pendingBashExchanges: { entryId: string; xml: string }[] = [];
|
|
140
|
+
// Optional follow-up user prompts queued from sendShellContext (the
|
|
141
|
+
// multi-line `!cmd\n<followup>` form). Fires sendMessage(followUp)
|
|
142
|
+
// after the bash exchange's XML is buffered, so the followUp goes out
|
|
143
|
+
// as the user message that drains the buffer.
|
|
144
|
+
const pendingFollowUps = new Map<string, string>();
|
|
145
|
+
|
|
146
|
+
// Side-effect-only: scrape bash-* replay frames for shellEntries chunks
|
|
147
|
+
// + build the buffered XML that rides out on the next sendMessage.
|
|
148
|
+
// Always returns false so the frame still flows into messagesCtrl for
|
|
149
|
+
// chat-side rendering.
|
|
150
|
+
function handleShellReplay(frame: InboundFrame): boolean {
|
|
151
|
+
if (!isUserFrame(frame) || !frame.isReplay) return false;
|
|
152
|
+
const c = frame.message.content;
|
|
153
|
+
if (typeof c !== "string") return false;
|
|
154
|
+
const parsed = parseBashFrame(c);
|
|
155
|
+
if (!parsed) return false;
|
|
156
|
+
|
|
157
|
+
if (parsed.kind === "input") {
|
|
158
|
+
// First replay frame for the head capture: the binary acked the
|
|
159
|
+
// command. Output is still pending.
|
|
160
|
+
const head = captureQueue[0];
|
|
161
|
+
if (head) head.sawInputEcho = true;
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
// Both "output" (output-only replay) and "merged" (some bridges emit
|
|
165
|
+
// input + output in one frame) close the head capture in FIFO order.
|
|
166
|
+
const cap = captureQueue.shift();
|
|
167
|
+
if (!cap) return false;
|
|
168
|
+
const parts: string[] = [];
|
|
169
|
+
if (parsed.stdout) parts.push(parsed.stdout);
|
|
170
|
+
if (parsed.stderr) parts.push("[stderr] " + parsed.stderr);
|
|
171
|
+
if (parsed.exit && parsed.exit !== "0") parts.push(`[exit ${parsed.exit}]`);
|
|
172
|
+
const chunk = parts.join("\n");
|
|
173
|
+
shellEntries.set(
|
|
174
|
+
shellEntries.get().map((s) => (s.id === cap.entryId ? { ...s, chunks: [...s.chunks, chunk] } : s)),
|
|
175
|
+
);
|
|
176
|
+
if (cap.source === "context") {
|
|
177
|
+
// Re-encode for the buffered XML — parseBashFrame decodes the
|
|
178
|
+
// entities; the outbound payload needs them re-escaped.
|
|
179
|
+
const stdoutXml = escapeXml(parsed.stdout);
|
|
180
|
+
const stderrXml = escapeXml(parsed.stderr);
|
|
181
|
+
pendingBashExchanges.push({
|
|
182
|
+
entryId: cap.entryId,
|
|
183
|
+
xml: buildBashXml(cap.command, stdoutXml, stderrXml),
|
|
184
|
+
});
|
|
185
|
+
const followUp = pendingFollowUps.get(cap.entryId);
|
|
186
|
+
if (followUp) {
|
|
187
|
+
pendingFollowUps.delete(cap.entryId);
|
|
188
|
+
sendMessage(followUp);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function handleLocalCommandOutput(frame: InboundFrame): boolean {
|
|
195
|
+
if (!isSystemFrame(frame) || frame.subtype !== "local_command_output") return false;
|
|
196
|
+
const head = captureQueue[0];
|
|
197
|
+
if (!head) return false;
|
|
198
|
+
const content = "content" in frame ? frame.content ?? "" : "";
|
|
199
|
+
shellEntries.set(
|
|
200
|
+
shellEntries.get().map((s) => (s.id === head.entryId ? { ...s, chunks: [...s.chunks, content] } : s)),
|
|
201
|
+
);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function sendShellContext(command: string, followUp = "") {
|
|
206
|
+
// Route `!cmd` to the binary via the bash_command wire frame: the
|
|
207
|
+
// binary runs it (with its own bounds, sandboxing, etc) and replays
|
|
208
|
+
// <bash-input>/<bash-stdout>/<bash-stderr>/<bash-exit-code> as
|
|
209
|
+
// user/isReplay frames over the wire. Empirically these replay
|
|
210
|
+
// frames are NOT auto-injected into claude's transcript — we have
|
|
211
|
+
// to ride them out ourselves on the next user message.
|
|
212
|
+
const id = crypto.randomUUID();
|
|
213
|
+
shellEntries.set([...shellEntries.get(), { id, command, source: "context", chunks: [], pending: true }]);
|
|
214
|
+
captureQueue.push({ entryId: id, command, source: "context", sawInputEcho: false });
|
|
215
|
+
ws.send({ type: "bash_command", command });
|
|
216
|
+
// If the user typed a follow-up prompt on subsequent lines of the
|
|
217
|
+
// !cmd input, fire it right after the bash exchange completes — the
|
|
218
|
+
// drain in sendMessage will prepend the XML to the followUp text.
|
|
219
|
+
if (followUp.trim()) {
|
|
220
|
+
pendingFollowUps.set(id, followUp);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function sendBashSideChannel(command: string) {
|
|
225
|
+
const id = crypto.randomUUID();
|
|
226
|
+
shellEntries.set([...shellEntries.get(), { id, command, source: "sideChannel", chunks: [] }]);
|
|
227
|
+
captureQueue.push({ entryId: id, command, source: "sideChannel", sawInputEcho: false });
|
|
228
|
+
ws.send({ type: "bash_command", command });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function dismissShellEntry(id: string) {
|
|
232
|
+
shellEntries.set(shellEntries.get().filter((s) => s.id !== id));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function hasPending(): boolean {
|
|
236
|
+
return pendingBashExchanges.length > 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Drain buffered context-shell exchanges. Drained shell entries get
|
|
240
|
+
// removed from shellEntries — the panel acts as a "queued exchange"
|
|
241
|
+
// indicator that empties when the user fires the message that flushes
|
|
242
|
+
// the buffer. The exchange remains visible in the chat scrollback.
|
|
243
|
+
function drainPending(text: string): string {
|
|
244
|
+
if (pendingBashExchanges.length === 0) return text;
|
|
245
|
+
const xml = pendingBashExchanges.map((p) => p.xml).join("\n");
|
|
246
|
+
const payload = text.trim() ? `${xml}\n\n${text}` : xml;
|
|
247
|
+
const drainedIds = new Set(pendingBashExchanges.map((p) => p.entryId));
|
|
248
|
+
shellEntries.set(shellEntries.get().filter((s) => !drainedIds.has(s.id)));
|
|
249
|
+
pendingBashExchanges.length = 0;
|
|
250
|
+
return payload;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function reset() {
|
|
254
|
+
shellEntries.set([]);
|
|
255
|
+
captureQueue.length = 0;
|
|
256
|
+
pendingBashExchanges.length = 0;
|
|
257
|
+
pendingFollowUps.clear();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
shellEntries,
|
|
262
|
+
handleShellReplay,
|
|
263
|
+
handleLocalCommandOutput,
|
|
264
|
+
sendShellContext,
|
|
265
|
+
sendBashSideChannel,
|
|
266
|
+
dismissShellEntry,
|
|
267
|
+
drainPending,
|
|
268
|
+
hasPending,
|
|
269
|
+
reset,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
package/src/tasks.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Task lifecycle: system:task_started / task_progress / task_updated /
|
|
2
|
+
// task_notification, plus sub-agent fan-out (frames carrying a non-null
|
|
3
|
+
// parent_tool_use_id are routed to the matching task's transcript instead
|
|
4
|
+
// of the main timeline).
|
|
5
|
+
|
|
6
|
+
import { atom, type WritableAtom } from "nanostores";
|
|
7
|
+
import type { MessageEntry } from "./messages";
|
|
8
|
+
import {
|
|
9
|
+
isSystemFrame,
|
|
10
|
+
type InboundFrame,
|
|
11
|
+
type SystemTaskNotification,
|
|
12
|
+
type SystemTaskProgress,
|
|
13
|
+
type SystemTaskStarted,
|
|
14
|
+
type SystemTaskUpdated,
|
|
15
|
+
type TaskUsageBlock,
|
|
16
|
+
} from "./protocol";
|
|
17
|
+
|
|
18
|
+
// task_id is the binary's local registry id and the value to pass to
|
|
19
|
+
// stopTask(). tool_use_id ties the task back to the tool_use block that
|
|
20
|
+
// spawned it (Bash, Agent, Task, etc) — used to render task progress
|
|
21
|
+
// alongside / inside the parent's tool_use card.
|
|
22
|
+
export type TaskStatus = "running" | "completed" | "failed" | "stopped";
|
|
23
|
+
|
|
24
|
+
export type TaskUsage = {
|
|
25
|
+
totalTokens?: number;
|
|
26
|
+
toolUses?: number;
|
|
27
|
+
durationMs?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type TaskEntry = {
|
|
31
|
+
taskId: string;
|
|
32
|
+
toolUseId?: string;
|
|
33
|
+
taskType?: string;
|
|
34
|
+
description: string;
|
|
35
|
+
workflowName?: string;
|
|
36
|
+
prompt?: string;
|
|
37
|
+
status: TaskStatus;
|
|
38
|
+
startTime: number;
|
|
39
|
+
lastUpdate: number;
|
|
40
|
+
lastToolName?: string;
|
|
41
|
+
summary?: string;
|
|
42
|
+
outputFile?: string;
|
|
43
|
+
usage?: TaskUsage;
|
|
44
|
+
// Sub-agent transcript (frames received with parent_tool_use_id matching
|
|
45
|
+
// toolUseId). Populated only for tasks that emit their own frames —
|
|
46
|
+
// bash tasks won't have these.
|
|
47
|
+
transcript: MessageEntry[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const TASKS_CAP = 100;
|
|
51
|
+
|
|
52
|
+
export function capTasksKeepRunning<T extends { status: string }>(arr: T[], cap: number): T[] {
|
|
53
|
+
if (arr.length <= cap) return arr;
|
|
54
|
+
// Evict oldest non-running entries first; if all are running, keep all.
|
|
55
|
+
const overflow = arr.length - cap;
|
|
56
|
+
const out: T[] = [];
|
|
57
|
+
let evictBudget = overflow;
|
|
58
|
+
for (const t of arr) {
|
|
59
|
+
if (evictBudget > 0 && t.status !== "running") {
|
|
60
|
+
evictBudget--;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
out.push(t);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function mapTaskUsage(u: TaskUsageBlock | undefined, prev?: TaskUsage): TaskUsage | undefined {
|
|
69
|
+
if (!u) return prev;
|
|
70
|
+
return {
|
|
71
|
+
totalTokens: u.total_tokens ?? prev?.totalTokens,
|
|
72
|
+
toolUses: u.tool_uses ?? prev?.toolUses,
|
|
73
|
+
durationMs: u.duration_ms ?? prev?.durationMs,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type TasksController = {
|
|
78
|
+
tasks: WritableAtom<TaskEntry[]>;
|
|
79
|
+
handleTaskEvent: (frame: InboundFrame) => boolean;
|
|
80
|
+
handleSubAgentFrame: (frame: InboundFrame) => boolean;
|
|
81
|
+
reset: () => void;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export function createTasksController(): TasksController {
|
|
85
|
+
const tasks = atom<TaskEntry[]>([]);
|
|
86
|
+
|
|
87
|
+
function upsertTask(taskId: string, mut: (t: TaskEntry) => TaskEntry, init?: () => TaskEntry) {
|
|
88
|
+
const arr = tasks.get();
|
|
89
|
+
const idx = arr.findIndex((t) => t.taskId === taskId);
|
|
90
|
+
if (idx === -1) {
|
|
91
|
+
if (!init) return;
|
|
92
|
+
tasks.set(capTasksKeepRunning([...arr, mut(init())], TASKS_CAP));
|
|
93
|
+
} else {
|
|
94
|
+
const next = arr.slice();
|
|
95
|
+
next[idx] = mut(arr[idx]!);
|
|
96
|
+
tasks.set(capTasksKeepRunning(next, TASKS_CAP));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// task_started bookend opens a task; progress updates the running entry;
|
|
101
|
+
// task_notification closes it with terminal status.
|
|
102
|
+
function handleTaskEvent(frame: InboundFrame): boolean {
|
|
103
|
+
if (!isSystemFrame(frame)) return false;
|
|
104
|
+
|
|
105
|
+
if (frame.subtype === "task_started") {
|
|
106
|
+
const f = frame as SystemTaskStarted;
|
|
107
|
+
if (!f.task_id) return false;
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
upsertTask(
|
|
110
|
+
f.task_id,
|
|
111
|
+
(t) => ({
|
|
112
|
+
...t,
|
|
113
|
+
toolUseId: f.tool_use_id ?? t.toolUseId,
|
|
114
|
+
taskType: f.task_type ?? t.taskType,
|
|
115
|
+
description: f.description ?? t.description,
|
|
116
|
+
workflowName: f.workflow_name ?? t.workflowName,
|
|
117
|
+
prompt: f.prompt ?? t.prompt,
|
|
118
|
+
status: "running",
|
|
119
|
+
lastUpdate: now,
|
|
120
|
+
}),
|
|
121
|
+
() => ({
|
|
122
|
+
taskId: f.task_id,
|
|
123
|
+
toolUseId: f.tool_use_id,
|
|
124
|
+
taskType: f.task_type,
|
|
125
|
+
description: f.description ?? "",
|
|
126
|
+
workflowName: f.workflow_name,
|
|
127
|
+
prompt: f.prompt,
|
|
128
|
+
status: "running",
|
|
129
|
+
startTime: now,
|
|
130
|
+
lastUpdate: now,
|
|
131
|
+
transcript: [],
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (frame.subtype === "task_progress") {
|
|
138
|
+
const f = frame as SystemTaskProgress;
|
|
139
|
+
if (!f.task_id) return false;
|
|
140
|
+
upsertTask(f.task_id, (t) => ({
|
|
141
|
+
...t,
|
|
142
|
+
description: f.description ?? t.description,
|
|
143
|
+
lastToolName: f.last_tool_name ?? t.lastToolName,
|
|
144
|
+
summary: f.summary ?? t.summary,
|
|
145
|
+
usage: mapTaskUsage(f.usage, t.usage),
|
|
146
|
+
lastUpdate: Date.now(),
|
|
147
|
+
}));
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// task_updated is the binary's immediate state-change frame (stop_task
|
|
152
|
+
// → status:"killed"). The bookend task_notification arrives later;
|
|
153
|
+
// flip status NOW so a killed task doesn't render as still-running.
|
|
154
|
+
if (frame.subtype === "task_updated") {
|
|
155
|
+
const f = frame as SystemTaskUpdated;
|
|
156
|
+
if (!f.task_id || !f.patch) return false;
|
|
157
|
+
const rawStatus = f.patch.status;
|
|
158
|
+
const mapped: TaskStatus | undefined =
|
|
159
|
+
rawStatus === "killed" ? "stopped" :
|
|
160
|
+
rawStatus === "completed" || rawStatus === "failed" || rawStatus === "stopped" ? rawStatus :
|
|
161
|
+
undefined;
|
|
162
|
+
upsertTask(f.task_id, (t) => ({
|
|
163
|
+
...t,
|
|
164
|
+
status: mapped ?? t.status,
|
|
165
|
+
lastUpdate: Date.now(),
|
|
166
|
+
}));
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (frame.subtype === "task_notification") {
|
|
171
|
+
const f = frame as SystemTaskNotification;
|
|
172
|
+
if (!f.task_id) return false;
|
|
173
|
+
const status: TaskStatus =
|
|
174
|
+
f.status === "completed" || f.status === "failed" || f.status === "stopped" ? f.status : "completed";
|
|
175
|
+
upsertTask(
|
|
176
|
+
f.task_id,
|
|
177
|
+
(t) => ({
|
|
178
|
+
...t,
|
|
179
|
+
status,
|
|
180
|
+
summary: f.summary ?? t.summary,
|
|
181
|
+
outputFile: f.output_file ?? t.outputFile,
|
|
182
|
+
usage: mapTaskUsage(f.usage, t.usage),
|
|
183
|
+
lastUpdate: Date.now(),
|
|
184
|
+
}),
|
|
185
|
+
() => ({
|
|
186
|
+
taskId: f.task_id,
|
|
187
|
+
toolUseId: f.tool_use_id,
|
|
188
|
+
taskType: undefined,
|
|
189
|
+
description: f.summary ?? "",
|
|
190
|
+
status,
|
|
191
|
+
startTime: Date.now(),
|
|
192
|
+
lastUpdate: Date.now(),
|
|
193
|
+
transcript: [],
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Sub-agent frames must be checked BEFORE messagesCtrl.ingest in the
|
|
203
|
+
// dispatch chain — the messages controller eats stream_event / assistant
|
|
204
|
+
// unconditionally, which would otherwise pollute the main timeline with
|
|
205
|
+
// sub-agent bubbles and starve the per-task transcript.
|
|
206
|
+
function handleSubAgentFrame(frame: InboundFrame): boolean {
|
|
207
|
+
const parent =
|
|
208
|
+
"parent_tool_use_id" in frame && typeof frame.parent_tool_use_id === "string"
|
|
209
|
+
? frame.parent_tool_use_id
|
|
210
|
+
: null;
|
|
211
|
+
if (!parent) return false;
|
|
212
|
+
const arr = tasks.get();
|
|
213
|
+
const idx = arr.findIndex((t) => t.toolUseId === parent);
|
|
214
|
+
// Race: task_started not yet observed. Fall through so the frame still
|
|
215
|
+
// appears somewhere — visible-but-misplaced beats dropped.
|
|
216
|
+
if (idx === -1) return false;
|
|
217
|
+
const next = arr.slice();
|
|
218
|
+
const cur = arr[idx]!;
|
|
219
|
+
next[idx] = {
|
|
220
|
+
...cur,
|
|
221
|
+
transcript: [
|
|
222
|
+
...cur.transcript,
|
|
223
|
+
{
|
|
224
|
+
kind: "frame" as const,
|
|
225
|
+
id: crypto.randomUUID(),
|
|
226
|
+
frame,
|
|
227
|
+
arrivalIdx: cur.transcript.length,
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
lastUpdate: Date.now(),
|
|
231
|
+
};
|
|
232
|
+
tasks.set(next);
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function reset() {
|
|
237
|
+
tasks.set([]);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
tasks,
|
|
242
|
+
handleTaskEvent,
|
|
243
|
+
handleSubAgentFrame,
|
|
244
|
+
reset,
|
|
245
|
+
};
|
|
246
|
+
}
|
package/src/usage.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Token usage helpers. The SDK's "total tokens" is
|
|
2
|
+
// input + cache_creation + cache_read + output
|
|
3
|
+
// — i.e. context-window load, not lifetime tokens (cache_read dominates
|
|
4
|
+
// once a session has any history). UIs that show "ctx %" want this sum.
|
|
5
|
+
|
|
6
|
+
export type UsageLike = {
|
|
7
|
+
input_tokens?: number;
|
|
8
|
+
cache_creation_input_tokens?: number;
|
|
9
|
+
cache_read_input_tokens?: number;
|
|
10
|
+
output_tokens?: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function sumContextTokens(u: UsageLike | null | undefined): number | null {
|
|
14
|
+
if (!u) return null;
|
|
15
|
+
const total =
|
|
16
|
+
(u.input_tokens ?? 0)
|
|
17
|
+
+ (u.cache_creation_input_tokens ?? 0)
|
|
18
|
+
+ (u.cache_read_input_tokens ?? 0)
|
|
19
|
+
+ (u.output_tokens ?? 0);
|
|
20
|
+
return Number.isFinite(total) ? total : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Pull the most-relevant `usage` block from a frame regardless of which
|
|
24
|
+
// canonical shape it took (`assistant`/`stream_event`-derived have it on
|
|
25
|
+
// `message.usage`; `result` puts it at the top level).
|
|
26
|
+
export function getFrameUsage(frame: unknown): UsageLike | null {
|
|
27
|
+
if (!frame || typeof frame !== "object") return null;
|
|
28
|
+
const f = frame as { usage?: UsageLike; message?: { usage?: UsageLike } };
|
|
29
|
+
return f.usage ?? f.message?.usage ?? null;
|
|
30
|
+
}
|
package/src/ws.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Thin WS wrapper. Does NOT auto-reconnect (deferred per LIB-DESIGN). One
|
|
2
|
+
// connection per createCcSession. NDJSON framing on the wire — every send is
|
|
3
|
+
// already a single JSON object; the bridge splits inbound on `\n`.
|
|
4
|
+
|
|
5
|
+
import { atom, type WritableAtom } from "nanostores";
|
|
6
|
+
import type { InboundFrame, OutboundFrame } from "./protocol";
|
|
7
|
+
|
|
8
|
+
export type WsStatus =
|
|
9
|
+
| "idle"
|
|
10
|
+
| "connecting"
|
|
11
|
+
| "open"
|
|
12
|
+
| "respawning"
|
|
13
|
+
| "closed"
|
|
14
|
+
| "error";
|
|
15
|
+
|
|
16
|
+
export type WsClient = {
|
|
17
|
+
status: WritableAtom<WsStatus>;
|
|
18
|
+
lastError: WritableAtom<string | null>;
|
|
19
|
+
connect: () => void;
|
|
20
|
+
disconnect: () => void;
|
|
21
|
+
send: (frame: OutboundFrame) => void;
|
|
22
|
+
// Internal: subscribers register here to receive parsed inbound frames.
|
|
23
|
+
// Avoids forcing a fan-out atom that would re-render every subscriber on
|
|
24
|
+
// every frame.
|
|
25
|
+
onFrame: (handler: (frame: InboundFrame) => void) => () => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function createWsClient(opts: {
|
|
29
|
+
url: string;
|
|
30
|
+
onTrace?: (dir: "in" | "out", line: string) => void;
|
|
31
|
+
}): WsClient {
|
|
32
|
+
const status = atom<WsStatus>("idle");
|
|
33
|
+
const lastError = atom<string | null>(null);
|
|
34
|
+
const handlers = new Set<(f: InboundFrame) => void>();
|
|
35
|
+
let ws: WebSocket | null = null;
|
|
36
|
+
|
|
37
|
+
function connect() {
|
|
38
|
+
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
status.set("connecting");
|
|
42
|
+
lastError.set(null);
|
|
43
|
+
ws = new WebSocket(opts.url);
|
|
44
|
+
ws.onopen = () => status.set("open");
|
|
45
|
+
ws.onclose = () => {
|
|
46
|
+
ws = null;
|
|
47
|
+
status.set("closed");
|
|
48
|
+
};
|
|
49
|
+
ws.onerror = () => {
|
|
50
|
+
lastError.set("ws error");
|
|
51
|
+
status.set("error");
|
|
52
|
+
};
|
|
53
|
+
ws.onmessage = (e) => {
|
|
54
|
+
const text = typeof e.data === "string" ? e.data : "";
|
|
55
|
+
if (!text) return;
|
|
56
|
+
opts.onTrace?.("in", text);
|
|
57
|
+
let parsed: InboundFrame;
|
|
58
|
+
try {
|
|
59
|
+
parsed = JSON.parse(text);
|
|
60
|
+
} catch {
|
|
61
|
+
// Bad frame — drop. We don't want one bad frame to take down the session.
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
for (const h of handlers) {
|
|
65
|
+
try {
|
|
66
|
+
h(parsed);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
// A handler throwing should not stop other handlers.
|
|
69
|
+
console.error("[ws] handler error", err);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function disconnect() {
|
|
76
|
+
if (ws) {
|
|
77
|
+
try { ws.close(); } catch {}
|
|
78
|
+
ws = null;
|
|
79
|
+
}
|
|
80
|
+
status.set("closed");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function send(frame: OutboundFrame) {
|
|
84
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
85
|
+
const line = JSON.stringify(frame);
|
|
86
|
+
opts.onTrace?.("out", line);
|
|
87
|
+
ws.send(line);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function onFrame(handler: (f: InboundFrame) => void) {
|
|
91
|
+
handlers.add(handler);
|
|
92
|
+
return () => {
|
|
93
|
+
handlers.delete(handler);
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { status, lastError, connect, disconnect, send, onFrame };
|
|
98
|
+
}
|