@codeoid/core 0.1.0 → 0.2.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/package.json +2 -2
- package/src/client.ts +28 -3
- package/src/slash.ts +63 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codeoid/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Framework-agnostic Codeoid client core — WebSocket transport (auth handshake, reconnect, heartbeat), message store semantics, resume cursors, and display helpers. Shared by the web UI and the mobile client.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -23,6 +23,6 @@
|
|
|
23
23
|
"access": "public"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
|
-
"@codeoid/protocol": "^0.
|
|
26
|
+
"@codeoid/protocol": "^0.2.0"
|
|
27
27
|
}
|
|
28
28
|
}
|
package/src/client.ts
CHANGED
|
@@ -34,6 +34,22 @@ export type ClientStatus =
|
|
|
34
34
|
| { kind: "reconnecting"; attempt: number; nextInMs: number; reason: string }
|
|
35
35
|
| { kind: "failed"; reason: string };
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The daemon actively REJECTED the presented token (auth `response.error`, or a
|
|
39
|
+
* close with the auth codes 4001/4003) — as opposed to a transient network
|
|
40
|
+
* drop. Retrying won't help: there's no refresh path for an OAuth JWT, and a
|
|
41
|
+
* key that fails re-exchange won't recover. The reconnect loop treats this as
|
|
42
|
+
* terminal (→ `failed`) so the host can fall back to sign-in instead of
|
|
43
|
+
* looping forever on a dead token.
|
|
44
|
+
*/
|
|
45
|
+
export class AuthRejectedError extends Error {
|
|
46
|
+
readonly authRejected = true as const;
|
|
47
|
+
constructor(message: string) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "AuthRejectedError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
37
53
|
export interface ConnectOptions {
|
|
38
54
|
url: string; // ws://host:port — daemon's WS endpoint
|
|
39
55
|
/** Initial access token used for the first handshake (and the fallback
|
|
@@ -331,7 +347,10 @@ export class CodeoidClient {
|
|
|
331
347
|
return auth;
|
|
332
348
|
} catch (err) {
|
|
333
349
|
const reason = err instanceof Error ? err.message : String(err);
|
|
334
|
-
|
|
350
|
+
// Terminal auth rejection (bad/expired token, no refresh path — e.g.
|
|
351
|
+
// an OAuth JWT that expired) never self-heals by retrying; fail so the
|
|
352
|
+
// host falls back to sign-in instead of looping forever on it.
|
|
353
|
+
if (err instanceof AuthRejectedError || (isBounded && attempt >= max)) {
|
|
335
354
|
this.#setStatus({ kind: "failed", reason });
|
|
336
355
|
throw err;
|
|
337
356
|
}
|
|
@@ -530,7 +549,7 @@ export class CodeoidClient {
|
|
|
530
549
|
if (!authResolved && msg.type === "response.error") {
|
|
531
550
|
authResolved = true;
|
|
532
551
|
clearTimeout(authTimer);
|
|
533
|
-
reject(new
|
|
552
|
+
reject(new AuthRejectedError(`auth rejected: ${msg.error}`));
|
|
534
553
|
return;
|
|
535
554
|
}
|
|
536
555
|
this.#routeMessage(msg);
|
|
@@ -540,7 +559,13 @@ export class CodeoidClient {
|
|
|
540
559
|
if (!authResolved) {
|
|
541
560
|
authResolved = true;
|
|
542
561
|
clearTimeout(authTimer);
|
|
543
|
-
|
|
562
|
+
// 4001 (auth handshake timeout) / 4003 (auth rejected) are the
|
|
563
|
+
// daemon's auth-failure close codes — terminal, not a retryable drop.
|
|
564
|
+
reject(
|
|
565
|
+
ev.code === 4001 || ev.code === 4003
|
|
566
|
+
? new AuthRejectedError(reason)
|
|
567
|
+
: new Error(reason),
|
|
568
|
+
);
|
|
544
569
|
return;
|
|
545
570
|
}
|
|
546
571
|
clearTimeout(authTimer);
|
package/src/slash.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import type {
|
|
18
18
|
ClientMessage,
|
|
19
|
+
SessionInfo,
|
|
19
20
|
SessionMode,
|
|
20
21
|
} from "@codeoid/protocol";
|
|
21
22
|
|
|
@@ -28,15 +29,26 @@ export type SlashCommand =
|
|
|
28
29
|
| { kind: "mode"; mode: SessionMode; maxTurns?: number }
|
|
29
30
|
| { kind: "model"; model: string; fallback?: string | null }
|
|
30
31
|
| { kind: "model-picker" }
|
|
32
|
+
| { kind: "provider"; providerId: string }
|
|
31
33
|
| { kind: "help" }
|
|
32
34
|
| { kind: "clear" }
|
|
33
35
|
| { kind: "who" }
|
|
34
36
|
| { kind: "capabilities"; tab: "agents" | "skills" | "mcp" | "hooks" }
|
|
35
37
|
| { kind: "export" }
|
|
36
38
|
| { kind: "import" }
|
|
37
|
-
| { kind: "fork" };
|
|
39
|
+
| { kind: "fork"; providerId?: string };
|
|
38
40
|
|
|
39
|
-
export
|
|
41
|
+
export interface ParseSlashOptions {
|
|
42
|
+
/**
|
|
43
|
+
* Provider-command passthrough (`session.commands` catalogs). When the
|
|
44
|
+
* verb is not a built-in and this predicate matches it, `parseSlash`
|
|
45
|
+
* returns null — "not a client command" — so the caller sends the raw
|
|
46
|
+
* text as a normal prompt and the session's provider expands it.
|
|
47
|
+
*/
|
|
48
|
+
isProviderCommand?: (name: string) => boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function parseSlash(raw: string, opts?: ParseSlashOptions): SlashCommand | null {
|
|
40
52
|
const trimmed = raw.trim();
|
|
41
53
|
if (!trimmed.startsWith("/")) return null;
|
|
42
54
|
const head = trimmed.slice(1);
|
|
@@ -96,6 +108,13 @@ export function parseSlash(raw: string): SlashCommand | null {
|
|
|
96
108
|
...(fallback !== undefined ? { fallback } : {}),
|
|
97
109
|
};
|
|
98
110
|
}
|
|
111
|
+
case "provider": {
|
|
112
|
+
// Switch the focused session's BACKEND (e.g. `/provider pi`). The
|
|
113
|
+
// daemon validates the id fail-closed and rejects mid-turn switches.
|
|
114
|
+
const providerId = rest[0]?.toLowerCase();
|
|
115
|
+
if (!providerId) throw new Error("/provider <id> — e.g. /provider pi");
|
|
116
|
+
return { kind: "provider", providerId };
|
|
117
|
+
}
|
|
99
118
|
case "help":
|
|
100
119
|
return { kind: "help" };
|
|
101
120
|
case "clear":
|
|
@@ -120,8 +139,14 @@ export function parseSlash(raw: string): SlashCommand | null {
|
|
|
120
139
|
case "import":
|
|
121
140
|
return { kind: "import" };
|
|
122
141
|
case "fork":
|
|
123
|
-
|
|
142
|
+
// `/fork [backend]` — branch the CURRENT session, optionally onto a
|
|
143
|
+
// different backend. (Historically an alias for /import, which
|
|
144
|
+
// silently discarded the argument and opened the bundle dialog.)
|
|
145
|
+
return { kind: "fork", providerId: rest[0]?.toLowerCase() };
|
|
124
146
|
default:
|
|
147
|
+
// Not a built-in. Provider commands (pi extensions, prompt templates,
|
|
148
|
+
// skills) pass through as plain prompt text — the provider expands them.
|
|
149
|
+
if (opts?.isProviderCommand?.(verb.toLowerCase())) return null;
|
|
125
150
|
throw new Error(`unknown slash command: /${verb}`);
|
|
126
151
|
}
|
|
127
152
|
}
|
|
@@ -147,6 +172,12 @@ export interface SlashContext {
|
|
|
147
172
|
showCapabilities?: (tab: "agents" | "skills" | "mcp" | "hooks") => void;
|
|
148
173
|
showExport?: () => void;
|
|
149
174
|
showImport?: () => void;
|
|
175
|
+
/**
|
|
176
|
+
* Called with the fork's SessionInfo after a successful `/fork`, so the
|
|
177
|
+
* frontend can add it to its store and focus it. Absent = the daemon's
|
|
178
|
+
* `session.list` broadcast is the only signal.
|
|
179
|
+
*/
|
|
180
|
+
onSessionForked?: (session: SessionInfo) => void;
|
|
150
181
|
}
|
|
151
182
|
|
|
152
183
|
export function dispatchSlash(cmd: SlashCommand, ctx: SlashContext): void {
|
|
@@ -224,6 +255,14 @@ export function dispatchSlash(cmd: SlashCommand, ctx: SlashContext): void {
|
|
|
224
255
|
case "model-picker":
|
|
225
256
|
ctx.showModelPicker?.();
|
|
226
257
|
return;
|
|
258
|
+
case "provider":
|
|
259
|
+
fire({
|
|
260
|
+
type: "session.set_provider",
|
|
261
|
+
id: ctx.newRequestId(),
|
|
262
|
+
sessionId: ctx.sessionId,
|
|
263
|
+
providerId: cmd.providerId,
|
|
264
|
+
});
|
|
265
|
+
return;
|
|
227
266
|
case "help":
|
|
228
267
|
ctx.showHelp?.();
|
|
229
268
|
return;
|
|
@@ -240,8 +279,28 @@ export function dispatchSlash(cmd: SlashCommand, ctx: SlashContext): void {
|
|
|
240
279
|
ctx.showExport?.();
|
|
241
280
|
return;
|
|
242
281
|
case "import":
|
|
243
|
-
case "fork":
|
|
244
282
|
ctx.showImport?.();
|
|
245
283
|
return;
|
|
284
|
+
case "fork": {
|
|
285
|
+
const msg: ClientMessage = {
|
|
286
|
+
type: "session.fork",
|
|
287
|
+
id: ctx.newRequestId(),
|
|
288
|
+
sessionId: ctx.sessionId,
|
|
289
|
+
...(cmd.providerId ? { providerId: cmd.providerId } : {}),
|
|
290
|
+
};
|
|
291
|
+
if (ctx.request) {
|
|
292
|
+
ctx
|
|
293
|
+
.request(msg)
|
|
294
|
+
.then((data) => {
|
|
295
|
+
if (data && typeof data === "object" && "id" in data) {
|
|
296
|
+
ctx.onSessionForked?.(data as SessionInfo);
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
.catch((e) => ctx.report?.(e instanceof Error ? e.message : String(e)));
|
|
300
|
+
} else {
|
|
301
|
+
ctx.send(msg);
|
|
302
|
+
}
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
246
305
|
}
|
|
247
306
|
}
|