@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.
@@ -0,0 +1,105 @@
1
+ // can_use_tool gate. The binary sends INBOUND control_request frames with
2
+ // subtype:"can_use_tool" when --permission-prompt-tool=stdio is set. We must
3
+ // reply with a CanUseToolResponseFrame (nested envelope) carrying either an
4
+ // allow or a deny.
5
+ //
6
+ // Two consumer surfaces share the same machinery:
7
+ // 1. promise callback — pass `onCanUseTool` to createCcSession; we resolve
8
+ // it for each request and dispatch the result.
9
+ // 2. queue + respondToPermission — subscribe to the `pendingPermissions`
10
+ // atom, render UI, call respondToPermission(id, decision).
11
+ // If `onCanUseTool` is provided it wins; the queue is then transient and
12
+ // callers should not consume it from UI.
13
+
14
+ import { atom, type WritableAtom } from "nanostores";
15
+ import {
16
+ isControlRequest,
17
+ type CanUseToolRequest,
18
+ type InboundFrame,
19
+ } from "./protocol";
20
+ import type { WsClient } from "./ws";
21
+
22
+ export type PermissionDecision =
23
+ | { behavior: "allow"; updatedInput?: unknown }
24
+ | { behavior: "deny"; message?: string };
25
+
26
+ export type PendingPermission = {
27
+ id: string; // request_id we'll reply to
28
+ toolName: string;
29
+ input: unknown;
30
+ raw: CanUseToolRequest;
31
+ };
32
+
33
+ export type OnCanUseTool = (
34
+ req: PendingPermission,
35
+ ) => Promise<PermissionDecision> | PermissionDecision;
36
+
37
+ export type PermissionsController = {
38
+ pendingPermissions: WritableAtom<PendingPermission[]>;
39
+ ingest: (frame: InboundFrame) => boolean;
40
+ respond: (id: string, decision: PermissionDecision) => void;
41
+ // Drop every queued can_use_tool request without replying. Called by
42
+ // session respawn/disconnect — the issuing claude is dead, so any reply
43
+ // would go nowhere; the queue is stale UI clutter.
44
+ clearQueue: () => void;
45
+ };
46
+
47
+ export function createPermissionsController(opts: {
48
+ ws: WsClient;
49
+ onCanUseTool?: OnCanUseTool;
50
+ }): PermissionsController {
51
+ const pendingPermissions = atom<PendingPermission[]>([]);
52
+
53
+ function reply(id: string, decision: PermissionDecision, originalInput: unknown) {
54
+ const inner =
55
+ decision.behavior === "allow"
56
+ ? { behavior: "allow" as const, updatedInput: decision.updatedInput ?? originalInput ?? {} }
57
+ : { behavior: "deny" as const, message: decision.message ?? "Denied by user" };
58
+ opts.ws.send({
59
+ type: "control_response",
60
+ response: {
61
+ subtype: "success",
62
+ request_id: id,
63
+ response: inner,
64
+ },
65
+ });
66
+ }
67
+
68
+ function respond(id: string, decision: PermissionDecision) {
69
+ const queue = pendingPermissions.get();
70
+ const entry = queue.find((p) => p.id === id);
71
+ if (!entry) return;
72
+ pendingPermissions.set(queue.filter((p) => p.id !== id));
73
+ reply(id, decision, entry.input);
74
+ }
75
+
76
+ function ingest(frame: InboundFrame): boolean {
77
+ if (!isControlRequest(frame) || frame.request.subtype !== "can_use_tool") return false;
78
+ const cr = frame as CanUseToolRequest;
79
+ const entry: PendingPermission = {
80
+ id: cr.request_id,
81
+ toolName: cr.request.tool_name ?? "?",
82
+ input: cr.request.input ?? {},
83
+ raw: cr,
84
+ };
85
+ if (opts.onCanUseTool) {
86
+ // Promise-style: resolve immediately, never enqueue.
87
+ Promise.resolve(opts.onCanUseTool(entry))
88
+ .then((decision) => reply(entry.id, decision, entry.input))
89
+ .catch((err) => {
90
+ console.error("[permissions] onCanUseTool threw", err);
91
+ reply(entry.id, { behavior: "deny", message: "handler error" }, entry.input);
92
+ });
93
+ } else {
94
+ // Queue-style: append for the consumer to handle via respond().
95
+ pendingPermissions.set([...pendingPermissions.get(), entry]);
96
+ }
97
+ return true;
98
+ }
99
+
100
+ function clearQueue() {
101
+ pendingPermissions.set([]);
102
+ }
103
+
104
+ return { pendingPermissions, ingest, respond, clearQueue };
105
+ }
@@ -0,0 +1,121 @@
1
+ // Local-storage persistence: sessionId + visible message timeline + active
2
+ // mode/model/effort. Without this, --continue restores claude's internal
3
+ // context but doesn't restream prior turns over stream-json — refresh would
4
+ // land on an empty bubble list.
5
+
6
+ import type { MessagesController, MessageEntry } from "./messages";
7
+ import type { Effort, PermissionMode } from "./modes";
8
+ import type { ReadableAtom } from "nanostores";
9
+
10
+ export type StorageLike = {
11
+ getItem: (key: string) => string | null;
12
+ setItem: (key: string, value: string) => void;
13
+ removeItem: (key: string) => void;
14
+ };
15
+
16
+ export type CcPersistenceOptions = {
17
+ enabled?: boolean;
18
+ storage?: StorageLike;
19
+ key?: string;
20
+ // Cap on serialized message entries. Streaming entries are never
21
+ // persisted (transient by definition).
22
+ maxMessages?: number;
23
+ };
24
+
25
+ export type PersistenceConfig = {
26
+ storage: StorageLike;
27
+ key: string;
28
+ maxMessages: number;
29
+ };
30
+
31
+ export type PersistedShape = {
32
+ sessionId: string | null;
33
+ messages?: MessageEntry[];
34
+ permissionMode?: PermissionMode;
35
+ model?: string;
36
+ effort?: Effort;
37
+ };
38
+
39
+ export function resolvePersistence(
40
+ opt: CcPersistenceOptions | false | undefined,
41
+ ): PersistenceConfig | null {
42
+ if (opt === false) return null;
43
+ // Default to localStorage in browsers; null on the server so SSR doesn't
44
+ // crash on missing globals.
45
+ const g = globalThis as { localStorage?: StorageLike };
46
+ const defaultStorage: StorageLike | null = g.localStorage ?? null;
47
+ const storage = opt?.storage ?? defaultStorage;
48
+ if (!storage) return null;
49
+ if (opt && opt.enabled === false) return null;
50
+ return {
51
+ storage,
52
+ key: opt?.key ?? "cc-ws-session",
53
+ maxMessages: opt?.maxMessages ?? 200,
54
+ };
55
+ }
56
+
57
+ export function loadPersisted(cfg: PersistenceConfig): PersistedShape | null {
58
+ try {
59
+ const raw = cfg.storage.getItem(cfg.key);
60
+ if (!raw) return null;
61
+ const parsed = JSON.parse(raw);
62
+ if (!parsed || typeof parsed !== "object") return null;
63
+ return parsed as PersistedShape;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ export function savePersisted(cfg: PersistenceConfig, payload: PersistedShape): void {
70
+ try {
71
+ // Streaming entries are transient; serializing would resurrect a
72
+ // half-decoded message on reload. Keep frame + local_user only.
73
+ const trimmed: PersistedShape = { ...payload };
74
+ if (Array.isArray(payload.messages)) {
75
+ const filtered = payload.messages.filter(
76
+ (m) => m.kind === "frame" || m.kind === "local_user",
77
+ );
78
+ trimmed.messages = filtered.slice(-cfg.maxMessages);
79
+ }
80
+ cfg.storage.setItem(cfg.key, JSON.stringify(trimmed));
81
+ } catch {
82
+ // Persistence is best-effort UX, not a correctness requirement.
83
+ }
84
+ }
85
+
86
+ // Wires up debounced save-on-change for the relevant atoms. We subscribe
87
+ // to messagesCtrl.revision (only bumps on non-streaming changes) rather
88
+ // than `messages` directly — otherwise every streaming token kicks the
89
+ // 250ms timer and we serialize the entire timeline every 250ms during a
90
+ // turn for changes that are about to be discarded by the streaming-entry
91
+ // filter in savePersisted anyway.
92
+ export function installPersistenceWriter(args: {
93
+ persistence: PersistenceConfig;
94
+ init: ReadableAtom<{ sessionId: string | null }>;
95
+ messagesCtrl: MessagesController;
96
+ activeMode: ReadableAtom<PermissionMode>;
97
+ activeModel: ReadableAtom<string>;
98
+ activeEffort: ReadableAtom<Effort | "">;
99
+ }) {
100
+ const { persistence, init, messagesCtrl, activeMode, activeModel, activeEffort } = args;
101
+ let saveTimer: ReturnType<typeof setTimeout> | null = null;
102
+ const flush = () => {
103
+ saveTimer = null;
104
+ savePersisted(persistence, {
105
+ sessionId: init.get().sessionId,
106
+ messages: messagesCtrl.messages.get(),
107
+ permissionMode: activeMode.get(),
108
+ model: activeModel.get() || undefined,
109
+ effort: activeEffort.get() || undefined,
110
+ });
111
+ };
112
+ const schedule = () => {
113
+ if (saveTimer != null) return;
114
+ saveTimer = setTimeout(flush, 250);
115
+ };
116
+ init.subscribe(schedule);
117
+ messagesCtrl.revision.subscribe(schedule);
118
+ activeMode.subscribe(schedule);
119
+ activeModel.subscribe(schedule);
120
+ activeEffort.subscribe(schedule);
121
+ }