@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/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# @somewhatintelligent/cc-ws-client
|
|
2
|
+
|
|
3
|
+
Framework-agnostic reactive WebSocket client for the cc-ws Claude Code bridge protocol. Reactive state via [nanostores](https://github.com/nanostores/nanostores).
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createCcSession } from "@somewhatintelligent/cc-ws-client";
|
|
7
|
+
|
|
8
|
+
const session = createCcSession({ url: "wss://example.com/ws" });
|
|
9
|
+
session.sendMessage("Hello");
|
|
10
|
+
|
|
11
|
+
// Subscribe via nanostores; or use the framework adapters:
|
|
12
|
+
// - @somewhatintelligent/cc-ws-react
|
|
13
|
+
// - @somewhatintelligent/cc-ws-svelte (workspace-only for now)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
See `LIB-DESIGN.md` in the source repo for the wire protocol.
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@somewhatintelligent/cc-ws-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Framework-agnostic reactive WebSocket client for the cc-ws Claude Code bridge protocol.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.ts",
|
|
11
|
+
"./protocol": "./src/protocol.ts",
|
|
12
|
+
"./modes": "./src/modes.ts"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"nanostores": "^1.3.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/controls.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Control-request layer. Tracks every outbound control_request by request_id
|
|
2
|
+
// and resolves a Promise when its matching control_response arrives. Higher
|
|
3
|
+
// layers (session.ts) compose this for set_permission_mode / set_model /
|
|
4
|
+
// get_settings / file_suggestions / interrupt / end_session.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
isControlResponse,
|
|
8
|
+
makeRequestId,
|
|
9
|
+
type OutboundControlRequestSubtype,
|
|
10
|
+
type InboundFrame,
|
|
11
|
+
} from "./protocol";
|
|
12
|
+
import type { WsClient } from "./ws";
|
|
13
|
+
|
|
14
|
+
// The wrapper carries the canonical control_response payload (we hand
|
|
15
|
+
// both back so callers that need the request_id or subtype can dig in;
|
|
16
|
+
// most just read .inner).
|
|
17
|
+
export type ControlResponseResult = {
|
|
18
|
+
inner: unknown;
|
|
19
|
+
wrapper: { subtype: "success"; request_id: string; response?: unknown };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type Resolver = {
|
|
23
|
+
resolve: (response: ControlResponseResult) => void;
|
|
24
|
+
reject: (error: string) => void;
|
|
25
|
+
timeoutId: ReturnType<typeof setTimeout>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ControlsClient = {
|
|
29
|
+
request: (
|
|
30
|
+
request: OutboundControlRequestSubtype,
|
|
31
|
+
opts?: { timeoutMs?: number },
|
|
32
|
+
) => Promise<ControlResponseResult>;
|
|
33
|
+
// For frames we DIDN'T initiate (can_use_tool comes IN as control_request).
|
|
34
|
+
// Returns true if the frame was handled (we matched it to an in-flight
|
|
35
|
+
// request).
|
|
36
|
+
ingest: (frame: InboundFrame) => boolean;
|
|
37
|
+
// Reject every in-flight request with `reason`. Called from session
|
|
38
|
+
// respawn/disconnect — the bridge kills the old claude child the
|
|
39
|
+
// moment we send respawn, so any outstanding control_request would
|
|
40
|
+
// otherwise hang against a dead pipe until its timeoutMs fires.
|
|
41
|
+
abortAll: (reason: string) => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function createControlsClient(ws: WsClient): ControlsClient {
|
|
45
|
+
const inflight = new Map<string, Resolver>();
|
|
46
|
+
|
|
47
|
+
function request(
|
|
48
|
+
body: OutboundControlRequestSubtype,
|
|
49
|
+
opts: { timeoutMs?: number } = {},
|
|
50
|
+
): Promise<ControlResponseResult> {
|
|
51
|
+
const requestId = makeRequestId();
|
|
52
|
+
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
53
|
+
return new Promise<ControlResponseResult>((resolve, reject) => {
|
|
54
|
+
const timeoutId = setTimeout(() => {
|
|
55
|
+
if (!inflight.has(requestId)) return;
|
|
56
|
+
inflight.delete(requestId);
|
|
57
|
+
reject(`timed out after ${timeoutMs}ms`);
|
|
58
|
+
}, timeoutMs);
|
|
59
|
+
inflight.set(requestId, { resolve, reject, timeoutId });
|
|
60
|
+
ws.send({
|
|
61
|
+
type: "control_request",
|
|
62
|
+
request_id: requestId,
|
|
63
|
+
request: body,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ingest(frame: InboundFrame): boolean {
|
|
69
|
+
if (!isControlResponse(frame)) return false;
|
|
70
|
+
// Real binary wire format may put request_id either at the outer
|
|
71
|
+
// envelope OR inside response. Check both before giving up.
|
|
72
|
+
const id = frame.response.request_id ?? frame.request_id;
|
|
73
|
+
if (!id) return false;
|
|
74
|
+
const r = inflight.get(id);
|
|
75
|
+
if (!r) return false;
|
|
76
|
+
inflight.delete(id);
|
|
77
|
+
clearTimeout(r.timeoutId);
|
|
78
|
+
if (frame.response.subtype === "success") {
|
|
79
|
+
r.resolve({ inner: frame.response.response, wrapper: frame.response });
|
|
80
|
+
} else {
|
|
81
|
+
r.reject(frame.response.error ?? "unknown error");
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function abortAll(reason: string) {
|
|
87
|
+
for (const [id, r] of inflight) {
|
|
88
|
+
clearTimeout(r.timeoutId);
|
|
89
|
+
r.reject(reason);
|
|
90
|
+
inflight.delete(id);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { request, ingest, abortAll };
|
|
95
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export { createCcSession } from "./session";
|
|
2
|
+
export {
|
|
3
|
+
parseBashFrame,
|
|
4
|
+
buildBashXml,
|
|
5
|
+
escapeXml,
|
|
6
|
+
decodeXml,
|
|
7
|
+
type BashFrame,
|
|
8
|
+
} from "./shell";
|
|
9
|
+
export {
|
|
10
|
+
sumContextTokens,
|
|
11
|
+
getFrameUsage,
|
|
12
|
+
type UsageLike,
|
|
13
|
+
} from "./usage";
|
|
14
|
+
export {
|
|
15
|
+
isAssistantFrame,
|
|
16
|
+
isControlRequest,
|
|
17
|
+
isControlResponse,
|
|
18
|
+
isLocalRespawnResult,
|
|
19
|
+
isResultFrame,
|
|
20
|
+
isStreamEventFrame,
|
|
21
|
+
isSystemFrame,
|
|
22
|
+
isUserFrame,
|
|
23
|
+
} from "./protocol";
|
|
24
|
+
export type {
|
|
25
|
+
CcAtoms,
|
|
26
|
+
CcSession,
|
|
27
|
+
CcSessionOptions,
|
|
28
|
+
CcPersistenceOptions,
|
|
29
|
+
HookEntry,
|
|
30
|
+
InitData,
|
|
31
|
+
SessionState,
|
|
32
|
+
ShellEntry,
|
|
33
|
+
ShellSource,
|
|
34
|
+
StorageLike,
|
|
35
|
+
TaskEntry,
|
|
36
|
+
TaskStatus,
|
|
37
|
+
TaskUsage,
|
|
38
|
+
} from "./session";
|
|
39
|
+
export type {
|
|
40
|
+
MessageEntry,
|
|
41
|
+
LocalUserEntry,
|
|
42
|
+
FrameEntry,
|
|
43
|
+
StreamingEntry,
|
|
44
|
+
InFlightMessage,
|
|
45
|
+
StreamingBlock,
|
|
46
|
+
TextBlock,
|
|
47
|
+
ToolUseBlock,
|
|
48
|
+
ThinkingBlock,
|
|
49
|
+
} from "./messages";
|
|
50
|
+
export type {
|
|
51
|
+
PendingPermission,
|
|
52
|
+
PermissionDecision,
|
|
53
|
+
OnCanUseTool,
|
|
54
|
+
} from "./permissions";
|
|
55
|
+
export type { WsStatus } from "./ws";
|
|
56
|
+
export type {
|
|
57
|
+
AssistantContentBlock,
|
|
58
|
+
AssistantFrame,
|
|
59
|
+
AssistantMessage,
|
|
60
|
+
ControlRequestFrame,
|
|
61
|
+
ControlResponseFrame,
|
|
62
|
+
HookSubtype,
|
|
63
|
+
InboundFrame,
|
|
64
|
+
ResultFrame,
|
|
65
|
+
SessionMode,
|
|
66
|
+
SessionStateValue,
|
|
67
|
+
StreamEvent,
|
|
68
|
+
StreamEventFrame,
|
|
69
|
+
SystemCompactBoundary,
|
|
70
|
+
SystemFrame,
|
|
71
|
+
SystemHook,
|
|
72
|
+
SystemInit,
|
|
73
|
+
SystemLocalCommandOutput,
|
|
74
|
+
SystemSessionStateChanged,
|
|
75
|
+
SystemStatus,
|
|
76
|
+
SystemTaskNotification,
|
|
77
|
+
SystemTaskProgress,
|
|
78
|
+
SystemTaskStarted,
|
|
79
|
+
SystemTaskUpdated,
|
|
80
|
+
TaskUpdatedPatch,
|
|
81
|
+
TaskUsageBlock,
|
|
82
|
+
TextContentBlock,
|
|
83
|
+
ThinkingContentBlock,
|
|
84
|
+
ToolResultContentBlock,
|
|
85
|
+
ToolUseContentBlock,
|
|
86
|
+
UnknownSystemFrame,
|
|
87
|
+
UsageBlock,
|
|
88
|
+
UserContentBlock,
|
|
89
|
+
UserFrame,
|
|
90
|
+
UserMessage,
|
|
91
|
+
} from "./protocol";
|
|
92
|
+
export {
|
|
93
|
+
PERMISSION_MODE_DEFS,
|
|
94
|
+
PERMISSION_MODE_OPTIONS,
|
|
95
|
+
KNOWN_PERMISSION_MODES,
|
|
96
|
+
CYCLE_ORDER,
|
|
97
|
+
nextCycleMode,
|
|
98
|
+
MODEL_DEFS,
|
|
99
|
+
MODEL_OPTIONS,
|
|
100
|
+
KNOWN_MODELS,
|
|
101
|
+
EFFORT_DEFS,
|
|
102
|
+
EFFORT_OPTIONS,
|
|
103
|
+
KNOWN_EFFORTS,
|
|
104
|
+
} from "./modes";
|
|
105
|
+
export type {
|
|
106
|
+
PermissionMode,
|
|
107
|
+
Model,
|
|
108
|
+
Effort,
|
|
109
|
+
} from "./modes";
|
package/src/messages.ts
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// Conversation log. Holds:
|
|
2
|
+
// - Local user echoes (sent prompts mirrored into the chat).
|
|
3
|
+
// - Raw inbound frames (assistant, user, result, control_*, etc).
|
|
4
|
+
// - In-flight assistant messages reconstructed from `stream_event` deltas.
|
|
5
|
+
//
|
|
6
|
+
// The `messages` atom is the canonical timeline. Streaming messages live as
|
|
7
|
+
// entries with kind:"streaming"; when the canonical `assistant` envelope
|
|
8
|
+
// arrives, the streaming entry is removed and replaced by the final
|
|
9
|
+
// `frame` entry (the renderer treats the assistant frame as authoritative).
|
|
10
|
+
|
|
11
|
+
import { atom, type WritableAtom } from "nanostores";
|
|
12
|
+
import type { InboundFrame, StreamEvent } from "./protocol";
|
|
13
|
+
|
|
14
|
+
// ---------- streaming reconstruction ----------
|
|
15
|
+
|
|
16
|
+
export type TextBlock = { type: "text"; text: string };
|
|
17
|
+
export type ToolUseBlock = {
|
|
18
|
+
type: "tool_use";
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
input: unknown;
|
|
22
|
+
// partial_json deltas accumulate here, parsed into `input` on
|
|
23
|
+
// content_block_stop. _parsed flips true once parsed (or on empty input
|
|
24
|
+
// close).
|
|
25
|
+
_partialJson: string;
|
|
26
|
+
_parsed: boolean;
|
|
27
|
+
};
|
|
28
|
+
export type ThinkingBlock = { type: "thinking"; thinking: string };
|
|
29
|
+
export type StreamingBlock = TextBlock | ToolUseBlock | ThinkingBlock;
|
|
30
|
+
|
|
31
|
+
export type InFlightMessage = {
|
|
32
|
+
id: string;
|
|
33
|
+
role: "assistant";
|
|
34
|
+
model?: string;
|
|
35
|
+
content: StreamingBlock[];
|
|
36
|
+
done: boolean;
|
|
37
|
+
arrivalIdx: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ---------- timeline entries ----------
|
|
41
|
+
|
|
42
|
+
export type LocalUserEntry = {
|
|
43
|
+
kind: "local_user";
|
|
44
|
+
id: string;
|
|
45
|
+
text: string;
|
|
46
|
+
arrivalIdx: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type FrameEntry = {
|
|
50
|
+
kind: "frame";
|
|
51
|
+
id: string;
|
|
52
|
+
frame: InboundFrame;
|
|
53
|
+
arrivalIdx: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type StreamingEntry = {
|
|
57
|
+
kind: "streaming";
|
|
58
|
+
id: string;
|
|
59
|
+
msg: InFlightMessage;
|
|
60
|
+
arrivalIdx: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type MessageEntry = LocalUserEntry | FrameEntry | StreamingEntry;
|
|
64
|
+
|
|
65
|
+
export type MessagesController = {
|
|
66
|
+
messages: WritableAtom<MessageEntry[]>;
|
|
67
|
+
activeStreamId: WritableAtom<string | null>;
|
|
68
|
+
// Bumps on every non-streaming change (pushFrame / pushLocalUser /
|
|
69
|
+
// dropStreaming / reset / hydrate). Stays still during streaming
|
|
70
|
+
// deltas. Persistence and other "save on stable change" consumers
|
|
71
|
+
// should subscribe to this rather than `messages` to avoid paying
|
|
72
|
+
// a serialization tax once per 250ms while a turn is streaming.
|
|
73
|
+
revision: WritableAtom<number>;
|
|
74
|
+
pushLocalUser: (text: string) => void;
|
|
75
|
+
pushFrame: (frame: InboundFrame) => void;
|
|
76
|
+
// Returns true if the frame was consumed by the streaming machinery and
|
|
77
|
+
// should NOT be appended to the timeline as a frame entry.
|
|
78
|
+
ingest: (frame: InboundFrame) => boolean;
|
|
79
|
+
reset: () => void;
|
|
80
|
+
// Replace the timeline with a saved snapshot (used by persistence on
|
|
81
|
+
// reload). Streaming entries are dropped — they're transient and don't
|
|
82
|
+
// round-trip through serialization.
|
|
83
|
+
hydrate: (entries: MessageEntry[]) => void;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export function createMessagesController(): MessagesController {
|
|
87
|
+
const messages = atom<MessageEntry[]>([]);
|
|
88
|
+
const activeStreamId = atom<string | null>(null);
|
|
89
|
+
const revision = atom(0);
|
|
90
|
+
function bumpRevision() {
|
|
91
|
+
revision.set(revision.get() + 1);
|
|
92
|
+
}
|
|
93
|
+
// Streaming reconstructions, keyed by message id. Mutated in place; each
|
|
94
|
+
// delta bumps the messages atom by emitting a new array reference so
|
|
95
|
+
// subscribers re-render. We accept the array-replacement cost for the
|
|
96
|
+
// simpler reactivity contract — there's typically ≤1 active streaming
|
|
97
|
+
// message at a time.
|
|
98
|
+
const streaming = new Map<string, InFlightMessage>();
|
|
99
|
+
let arrivalCounter = 0;
|
|
100
|
+
|
|
101
|
+
function nextIdx() {
|
|
102
|
+
return arrivalCounter++;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function pushLocalUser(text: string) {
|
|
106
|
+
const id = crypto.randomUUID();
|
|
107
|
+
const entry: LocalUserEntry = {
|
|
108
|
+
kind: "local_user",
|
|
109
|
+
id,
|
|
110
|
+
text,
|
|
111
|
+
arrivalIdx: nextIdx(),
|
|
112
|
+
};
|
|
113
|
+
messages.set([...messages.get(), entry]);
|
|
114
|
+
bumpRevision();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function pushFrame(frame: InboundFrame) {
|
|
118
|
+
const entry: FrameEntry = {
|
|
119
|
+
kind: "frame",
|
|
120
|
+
id: crypto.randomUUID(),
|
|
121
|
+
frame,
|
|
122
|
+
arrivalIdx: nextIdx(),
|
|
123
|
+
};
|
|
124
|
+
messages.set([...messages.get(), entry]);
|
|
125
|
+
bumpRevision();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function startStreaming(id: string, msg: InFlightMessage) {
|
|
129
|
+
streaming.set(id, msg);
|
|
130
|
+
const entry: StreamingEntry = {
|
|
131
|
+
kind: "streaming",
|
|
132
|
+
id,
|
|
133
|
+
msg,
|
|
134
|
+
arrivalIdx: nextIdx(),
|
|
135
|
+
};
|
|
136
|
+
messages.set([...messages.get(), entry]);
|
|
137
|
+
activeStreamId.set(id);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function bumpStreaming(id: string) {
|
|
141
|
+
// Per-token rebump. Replace just the streaming entry's identity so
|
|
142
|
+
// subscribers re-render — the streaming entry always lives at the
|
|
143
|
+
// tail (we never push frames after a stream starts until message_stop)
|
|
144
|
+
// so we can mutate the array in place rather than scan with .map.
|
|
145
|
+
const msg = streaming.get(id);
|
|
146
|
+
if (!msg) return;
|
|
147
|
+
const arr = messages.get();
|
|
148
|
+
const last = arr.length - 1;
|
|
149
|
+
if (last < 0) return;
|
|
150
|
+
const tail = arr[last];
|
|
151
|
+
if (tail?.kind !== "streaming" || tail.id !== id) {
|
|
152
|
+
const idx = arr.findIndex((e) => e.kind === "streaming" && e.id === id);
|
|
153
|
+
if (idx === -1) return;
|
|
154
|
+
const next = arr.slice();
|
|
155
|
+
next[idx] = { ...arr[idx]!, msg: { ...msg } } as StreamingEntry;
|
|
156
|
+
messages.set(next);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const next = arr.slice();
|
|
160
|
+
next[last] = { ...tail, msg: { ...msg } };
|
|
161
|
+
messages.set(next);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function dropStreaming(id: string) {
|
|
165
|
+
streaming.delete(id);
|
|
166
|
+
if (activeStreamId.get() === id) activeStreamId.set(null);
|
|
167
|
+
messages.set(messages.get().filter((e) => !(e.kind === "streaming" && e.id === id)));
|
|
168
|
+
bumpRevision();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function applyStreamEvent(ev: StreamEvent) {
|
|
172
|
+
if (ev.type === "message_start" && ev.message?.id) {
|
|
173
|
+
const id = ev.message.id;
|
|
174
|
+
const msg: InFlightMessage = {
|
|
175
|
+
id,
|
|
176
|
+
role: "assistant",
|
|
177
|
+
model: ev.message.model,
|
|
178
|
+
content: [],
|
|
179
|
+
done: false,
|
|
180
|
+
arrivalIdx: arrivalCounter,
|
|
181
|
+
};
|
|
182
|
+
startStreaming(id, msg);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (ev.type === "content_block_start") {
|
|
186
|
+
const id = activeStreamId.get();
|
|
187
|
+
const msg = id ? streaming.get(id) : null;
|
|
188
|
+
if (!msg) return;
|
|
189
|
+
const cb = ev.content_block ?? {};
|
|
190
|
+
let block: StreamingBlock;
|
|
191
|
+
if (cb.type === "tool_use") {
|
|
192
|
+
block = {
|
|
193
|
+
type: "tool_use",
|
|
194
|
+
id: cb.id ?? "",
|
|
195
|
+
name: cb.name ?? "",
|
|
196
|
+
input: cb.input ?? {},
|
|
197
|
+
_partialJson: "",
|
|
198
|
+
_parsed: false,
|
|
199
|
+
};
|
|
200
|
+
} else if (cb.type === "thinking") {
|
|
201
|
+
block = { type: "thinking", thinking: cb.thinking ?? "" };
|
|
202
|
+
} else {
|
|
203
|
+
block = { type: "text", text: cb.text ?? "" };
|
|
204
|
+
}
|
|
205
|
+
msg.content[ev.index] = block;
|
|
206
|
+
bumpStreaming(id!);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (ev.type === "content_block_delta") {
|
|
210
|
+
const id = activeStreamId.get();
|
|
211
|
+
const msg = id ? streaming.get(id) : null;
|
|
212
|
+
if (!msg) return;
|
|
213
|
+
const block = msg.content[ev.index];
|
|
214
|
+
const delta = ev.delta ?? {};
|
|
215
|
+
if (block?.type === "text" && delta.type === "text_delta" && typeof delta.text === "string") {
|
|
216
|
+
block.text += delta.text;
|
|
217
|
+
} else if (
|
|
218
|
+
block?.type === "tool_use" &&
|
|
219
|
+
delta.type === "input_json_delta" &&
|
|
220
|
+
typeof delta.partial_json === "string"
|
|
221
|
+
) {
|
|
222
|
+
block._partialJson += delta.partial_json;
|
|
223
|
+
} else if (
|
|
224
|
+
block?.type === "thinking" &&
|
|
225
|
+
delta.type === "thinking_delta" &&
|
|
226
|
+
typeof delta.thinking === "string"
|
|
227
|
+
) {
|
|
228
|
+
block.thinking += delta.thinking;
|
|
229
|
+
}
|
|
230
|
+
bumpStreaming(id!);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (ev.type === "content_block_stop") {
|
|
234
|
+
const id = activeStreamId.get();
|
|
235
|
+
const msg = id ? streaming.get(id) : null;
|
|
236
|
+
if (!msg) return;
|
|
237
|
+
const block = msg.content[ev.index];
|
|
238
|
+
if (block?.type === "tool_use" && !block._parsed) {
|
|
239
|
+
if (block._partialJson) {
|
|
240
|
+
try {
|
|
241
|
+
block.input = JSON.parse(block._partialJson);
|
|
242
|
+
block._parsed = true;
|
|
243
|
+
} catch (err) {
|
|
244
|
+
console.warn("[stream_event] tool_use partial_json parse failed", err, block._partialJson);
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
block._parsed = true;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
bumpStreaming(id!);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (ev.type === "message_stop") {
|
|
254
|
+
const id = activeStreamId.get();
|
|
255
|
+
const msg = id ? streaming.get(id) : null;
|
|
256
|
+
if (msg) msg.done = true;
|
|
257
|
+
if (id) bumpStreaming(id);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function ingest(frame: InboundFrame): boolean {
|
|
262
|
+
if (!("type" in frame)) return false;
|
|
263
|
+
// Streaming deltas: consume and never append the raw frame.
|
|
264
|
+
if (frame.type === "stream_event" && frame.event) {
|
|
265
|
+
applyStreamEvent(frame.event);
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
// Canonical assistant envelope: drop any matching streaming reconstruction
|
|
269
|
+
// (the envelope is authoritative), then append the frame.
|
|
270
|
+
if (frame.type === "assistant" && frame.message?.id) {
|
|
271
|
+
const id = frame.message.id;
|
|
272
|
+
if (streaming.has(id)) dropStreaming(id);
|
|
273
|
+
pushFrame(frame);
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function hydrate(entries: MessageEntry[]) {
|
|
280
|
+
streaming.clear();
|
|
281
|
+
activeStreamId.set(null);
|
|
282
|
+
const filtered = entries.filter(
|
|
283
|
+
(e): e is LocalUserEntry | FrameEntry => e.kind === "frame" || e.kind === "local_user",
|
|
284
|
+
);
|
|
285
|
+
// Reseat arrivalCounter past the last hydrated entry so subsequent
|
|
286
|
+
// pushes don't collide with restored ids in render order.
|
|
287
|
+
arrivalCounter = filtered.length > 0
|
|
288
|
+
? Math.max(...filtered.map((e) => e.arrivalIdx)) + 1
|
|
289
|
+
: 0;
|
|
290
|
+
messages.set(filtered);
|
|
291
|
+
bumpRevision();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function reset() {
|
|
295
|
+
streaming.clear();
|
|
296
|
+
activeStreamId.set(null);
|
|
297
|
+
messages.set([]);
|
|
298
|
+
arrivalCounter = 0;
|
|
299
|
+
bumpRevision();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
messages,
|
|
304
|
+
activeStreamId,
|
|
305
|
+
revision,
|
|
306
|
+
pushLocalUser,
|
|
307
|
+
pushFrame,
|
|
308
|
+
ingest,
|
|
309
|
+
reset,
|
|
310
|
+
hydrate,
|
|
311
|
+
};
|
|
312
|
+
}
|
package/src/modes.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Single source of truth for permission modes / models / efforts. Mirrors the
|
|
2
|
+
// canonical order found in the Claude Code v2.1.132 binary (see
|
|
3
|
+
// apps/slopbox/.spec/artifacts/extracted/canonical.json — PermissionMode +
|
|
4
|
+
// EffortLevel enums; getNextPermissionMode for cycle order).
|
|
5
|
+
|
|
6
|
+
export const PERMISSION_MODE_DEFS = [
|
|
7
|
+
{ value: "default", label: "Default (ask)", inCycle: true },
|
|
8
|
+
{ value: "acceptEdits", label: "Auto-accept edits", inCycle: true },
|
|
9
|
+
{ value: "plan", label: "Plan mode (no tool execution)", inCycle: true },
|
|
10
|
+
{ value: "bypassPermissions", label: "Bypass all (dangerous)", inCycle: true },
|
|
11
|
+
{ value: "auto", label: "Auto (classifier)", inCycle: true },
|
|
12
|
+
{ value: "dontAsk", label: "Don't ask", inCycle: false },
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
export type PermissionMode = (typeof PERMISSION_MODE_DEFS)[number]["value"];
|
|
16
|
+
|
|
17
|
+
export const PERMISSION_MODE_OPTIONS: { value: PermissionMode; label: string }[] =
|
|
18
|
+
PERMISSION_MODE_DEFS.map((m) => ({ value: m.value, label: m.label }));
|
|
19
|
+
|
|
20
|
+
export const KNOWN_PERMISSION_MODES: readonly PermissionMode[] =
|
|
21
|
+
PERMISSION_MODE_DEFS.map((m) => m.value);
|
|
22
|
+
|
|
23
|
+
export const CYCLE_ORDER: readonly PermissionMode[] = PERMISSION_MODE_DEFS
|
|
24
|
+
.filter((m) => m.inCycle)
|
|
25
|
+
.map((m) => m.value);
|
|
26
|
+
|
|
27
|
+
export function nextCycleMode(current: PermissionMode): PermissionMode {
|
|
28
|
+
const idx = CYCLE_ORDER.indexOf(current);
|
|
29
|
+
if (idx === -1) return "default";
|
|
30
|
+
return CYCLE_ORDER[(idx + 1) % CYCLE_ORDER.length]!;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Models the binary advertises. set_model takes the value verbatim; the
|
|
34
|
+
// binary resolves aliases. Display order = array order.
|
|
35
|
+
export const MODEL_DEFS = [
|
|
36
|
+
{ value: "claude-opus-4-7", label: "Opus 4.7" },
|
|
37
|
+
{ value: "claude-opus-4-7[1m]", label: "Opus 4.7 (1M)" },
|
|
38
|
+
{ value: "claude-opus-4-6", label: "Opus 4.6" },
|
|
39
|
+
{ value: "claude-opus-4-6[1m]", label: "Opus 4.6 (1M)" },
|
|
40
|
+
{ value: "claude-sonnet-4-6", label: "Sonnet 4.6" },
|
|
41
|
+
{ value: "claude-sonnet-4-5", label: "Sonnet 4.5" },
|
|
42
|
+
{ value: "claude-sonnet-4-5[1m]", label: "Sonnet 4.5 (1M)" },
|
|
43
|
+
{ value: "claude-haiku-4-5", label: "Haiku 4.5" },
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
export type Model = (typeof MODEL_DEFS)[number]["value"];
|
|
47
|
+
|
|
48
|
+
export const MODEL_OPTIONS: { value: Model; label: string }[] =
|
|
49
|
+
MODEL_DEFS.map((m) => ({ value: m.value, label: m.label }));
|
|
50
|
+
|
|
51
|
+
export const KNOWN_MODELS: readonly Model[] = MODEL_DEFS.map((m) => m.value);
|
|
52
|
+
|
|
53
|
+
// Effort levels accepted by --effort. No set_effort control_request exists,
|
|
54
|
+
// so changing effort mid-session triggers a respawn.
|
|
55
|
+
export const EFFORT_DEFS = [
|
|
56
|
+
{ value: "low", label: "Low" },
|
|
57
|
+
{ value: "medium", label: "Medium" },
|
|
58
|
+
{ value: "high", label: "High" },
|
|
59
|
+
{ value: "xhigh", label: "xHigh" },
|
|
60
|
+
{ value: "max", label: "Max" },
|
|
61
|
+
] as const;
|
|
62
|
+
|
|
63
|
+
export type Effort = (typeof EFFORT_DEFS)[number]["value"];
|
|
64
|
+
|
|
65
|
+
export const EFFORT_OPTIONS: { value: Effort; label: string }[] =
|
|
66
|
+
EFFORT_DEFS.map((e) => ({ value: e.value, label: e.label }));
|
|
67
|
+
|
|
68
|
+
export const KNOWN_EFFORTS: readonly Effort[] = EFFORT_DEFS.map((e) => e.value);
|