@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codeoid/core",
3
- "version": "0.1.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.1.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
- if (isBounded && attempt >= max) {
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 Error(`auth rejected: ${msg.error}`));
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
- reject(new Error(reason));
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 function parseSlash(raw: string): SlashCommand | null {
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
- return { kind: "fork" };
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
  }