shraga 0.1.12 → 0.1.13

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.
@@ -13,7 +13,7 @@
13
13
  <link rel="preconnect" href="https://fonts.googleapis.com" />
14
14
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
15
15
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
16
- <script type="module" crossorigin src="/assets/index-FM42KQNo.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-ChElotX8.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-DdibEb2O.css">
18
18
  </head>
19
19
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -57,7 +57,6 @@
57
57
  "dependencies": {
58
58
  "@anthropic-ai/claude-agent-sdk": "^0.2.141",
59
59
  "@livx.cc/mcp-firebase": "^0.1.14",
60
- "@livx.cc/native-kit": "^0.35.0",
61
60
  "@radix-ui/react-accordion": "^1.2.2",
62
61
  "@radix-ui/react-dialog": "^1.1.4",
63
62
  "@radix-ui/react-scroll-area": "^1.2.2",
package/src/cli.ts CHANGED
@@ -25,6 +25,11 @@ Options:
25
25
  -d, --data-dir <path> Data directory (default: ./data, or DATA_DIR env)
26
26
  -h, --help Show this help
27
27
 
28
+ Subcommands:
29
+ ingress Run the host-header TCP router (INGRESS_PORT, default 3100)
30
+ for previews + blue-green flips. Own process, survives restarts.
31
+ user add <email> <pw> Seed a local username/password user
32
+
28
33
  Environment:
29
34
  CLOUDFLARE_TUNNEL_TOKEN If set, starts a Cloudflare Tunnel alongside the server.
30
35
  Get the token from Cloudflare Zero Trust > Tunnels > Configure.
@@ -54,6 +59,14 @@ if (args[0] === 'user' && args[1] === 'add') {
54
59
  process.exit(0);
55
60
  }
56
61
 
62
+ // `shraga ingress` — host-header TCP router for previews + blue-green flips.
63
+ // Runs as its OWN process (INGRESS_PORT), deliberately separate from the server so it
64
+ // survives server restarts during a flip. Reads dataPath('ingress-router.json').
65
+ if (args[0] === 'ingress') {
66
+ await import('./server/ingress-router.ts');
67
+ // ingress-router keeps the process alive via its listening socket; do not fall through.
68
+ } else {
69
+
57
70
  let tunnel: ChildProcess | null = null;
58
71
 
59
72
  const tunnelToken = process.env.CLOUDFLARE_TUNNEL_TOKEN;
@@ -87,3 +100,5 @@ process.on('SIGTERM', cleanup);
87
100
  // Dogfood the public library surface — the CLI's server-run path IS createShraga(...).start().
88
101
  const { createShraga, fromEnv } = await import('./index.ts');
89
102
  await createShraga(fromEnv()).start();
103
+
104
+ } // end non-ingress server path
@@ -1,75 +1,28 @@
1
- // Draw the user's attention in the appwrap DESKTOP shell (Tauri/macOS) when a response finishes or
2
- // something needs input while the window is NOT focused. Every path is a silent no-op on web,
3
- // mobile, or when no native shell is connected so the app is unchanged everywhere else.
4
- //
5
- // Two shell methods are used (see runtime-desktop/src-tauri/src/main.rs):
6
- // • app.requestAttention { critical } → bounces the Dock icon (single bounce vs. until-focus)
7
- // • notifications.setBadge / clear → the Dock-icon badge (NSDockTile)
8
- // Older/other shells return UNSUPPORTED — every invoke is guarded so it can never break the app.
9
- import { kit } from '@livx.cc/native-kit';
1
+ // Desktop-attention facade over the native capability seam (nativeProvider.ts). Draws the user's
2
+ // attention in the appwrap DESKTOP shell (Dock bounce + badge) when a response finishes or input is
3
+ // needed while the window is unfocused. CE ships the web no-op provider, so every path is silent on
4
+ // web/mobile; an EE/native build registers the @livx.cc/native-kit-backed provider that implements it.
5
+ import { getNativeProvider } from './nativeProvider';
10
6
 
11
- let enabled = false;
12
- let initPromise: Promise<boolean> | null = null;
13
-
14
- // Warn once per failing method — an old shell missing a method (UNSUPPORTED) rejects on EVERY
15
- // call (each unread tick / permission_request / badge update); we surface it once, not as spam.
16
- const warned = new Set<string>();
17
- function warnOnce(key: string, msg: string, err: unknown): void {
18
- if (warned.has(key)) return;
19
- warned.add(key);
20
- console.warn(msg, err);
21
- }
22
-
23
- /**
24
- * One-time detection: are we inside the appwrap desktop shell? Resolves `true` only when the native
25
- * adapter won the handshake AND it reports platform 'desktop'. Idempotent; never rejects.
26
- * (`kit.platform` is typed 'ios' | 'android' | 'web' upstream, but the desktop shell reports the
27
- * runtime string 'desktop' — hence the cast.)
28
- */
7
+ /** One-time detection: are we inside the appwrap desktop shell? Idempotent; never rejects. */
29
8
  export function initDesktopAttention(): Promise<boolean> {
30
- if (!initPromise) {
31
- initPromise = kit
32
- .ready()
33
- .then(() => {
34
- enabled = kit.is.native && (kit.platform as string) === 'desktop';
35
- return enabled;
36
- })
37
- .catch((err) => {
38
- console.warn('[desktop] kit.ready() failed, attention disabled', err);
39
- return false;
40
- });
41
- }
42
- return initPromise;
9
+ return getNativeProvider().initDesktopAttention();
43
10
  }
44
11
 
45
12
  /** True only when running in the desktop shell. */
46
13
  export function isDesktopShell(): boolean {
47
- return enabled;
48
- }
49
-
50
- /** True when the window currently has OS focus (no bounce needed then). */
51
- function windowFocused(): boolean {
52
- return typeof document !== 'undefined' && document.hasFocus();
14
+ return getNativeProvider().isDesktopShell();
53
15
  }
54
16
 
55
17
  /**
56
- * Bounce the Dock icon to draw attention — but only in the desktop shell and only when the window
57
- * is NOT focused (a focused window needs no nudge). `blocking` = a question/approval waiting on the
58
- * user → critical bounce (until focus); otherwise a single informational bounce (completed response).
18
+ * Bounce the Dock icon to draw attention — desktop shell only, and only when the window is unfocused.
19
+ * `blocking` = a question/approval waiting on the user → critical bounce; otherwise a single bounce.
59
20
  */
60
21
  export function requestDesktopAttention(blocking = false): void {
61
- if (!enabled || windowFocused()) return;
62
- kit
63
- .invoke('app.requestAttention', { critical: blocking })
64
- .catch((err) => warnOnce('requestAttention', '[desktop] requestAttention failed', err));
22
+ getNativeProvider().requestDesktopAttention(blocking);
65
23
  }
66
24
 
67
- /**
68
- * Reflect the pending/unread count on the Dock-icon badge. No-op off the desktop shell.
69
- * `count <= 0` clears the badge.
70
- */
25
+ /** Reflect the pending/unread count on the Dock-icon badge. `count <= 0` clears it. No-op off desktop. */
71
26
  export function setDesktopBadge(count: number): void {
72
- if (!enabled) return;
73
- const p = count > 0 ? kit.notifications.setBadge(count) : kit.notifications.clear();
74
- p.catch((err) => warnOnce('setBadge', '[desktop] setBadge failed', err));
27
+ getNativeProvider().setDesktopBadge(count);
75
28
  }
@@ -1,96 +1,18 @@
1
- // Google sign-in for the appwrap native shell.
1
+ // Native Google sign-in facade over the native capability seam (nativeProvider.ts).
2
2
  //
3
- // Google rejects OAuth loaded inside an embedded WebView with `403 disallowed_useragent` (the
4
- // "Use secure browsers" policy). So inside the native shell we run the authorization-code + PKCE
5
- // flow in the SYSTEM browser via `kit.oauth` (ASWebAuthenticationSession), exchange the code for an
6
- // id_token, then hand it to Firebase via `signInWithCredential` same end state as the web popup.
7
- //
8
- // Gated on `VITE_GOOGLE_IOS_OAUTH_CLIENT_ID` (a per-instance iOS OAuth client created in that
9
- // instance's Firebase project for bundle `cc.livx.shraga`). Until it's set, the app uses the popup.
10
- // Mirrors the proven AGF appwrap implementation.
11
- import { kit } from '@livx.cc/native-kit';
12
- import { GoogleAuthProvider, signInWithCredential, type Auth } from 'firebase/auth';
13
- import { webConfig } from './webConfig';
14
-
15
- // Runtime server-injected id wins; the build-time VITE_ value is the fallback.
16
- const CLIENT_ID = webConfig.googleIosOAuthClientId ?? (import.meta.env.VITE_GOOGLE_IOS_OAUTH_CLIENT_ID as string | undefined);
17
-
18
- /** True only inside the native shell, with the oauth module compiled in and a client id configured. */
19
- export async function canUseNativeGoogleSignIn(): Promise<boolean> {
20
- if (!CLIENT_ID) return false;
21
- try {
22
- await kit.ready();
23
- } catch {
24
- return false;
25
- }
26
- return kit.is.native && kit.oauth.capability === 'native';
27
- }
28
-
29
- function base64Url(bytes: Uint8Array): string {
30
- let s = '';
31
- for (const b of bytes) s += String.fromCharCode(b);
32
- return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
33
- }
34
-
35
- async function sha256(input: string): Promise<Uint8Array> {
36
- const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
37
- return new Uint8Array(digest);
38
- }
39
-
40
- /** The reversed iOS OAuth client id IS the custom URL scheme Google redirects back to.
41
- * '1234-abc.apps.googleusercontent.com' → 'com.googleusercontent.apps.1234-abc' */
42
- function reversedClientId(clientId: string): string {
43
- return `com.googleusercontent.apps.${clientId.replace(/\.apps\.googleusercontent\.com$/, '')}`;
3
+ // Google rejects OAuth inside an embedded WebView (403 disallowed_useragent), so a native shell runs
4
+ // the auth-code + PKCE flow in the SYSTEM browser and hands the id_token to Firebase — same end state
5
+ // as the web popup. CE ships the web no-op provider (`canUseNativeGoogleSignIn` → false, so firebase.ts
6
+ // falls back to the popup); an EE/native build registers the @livx.cc/native-kit-backed implementation.
7
+ import type { Auth } from 'firebase/auth';
8
+ import { getNativeProvider } from './nativeProvider';
9
+
10
+ /** True only inside a native shell with a configured native Google OAuth client. */
11
+ export function canUseNativeGoogleSignIn(): Promise<boolean> {
12
+ return getNativeProvider().canUseNativeGoogleSignIn();
44
13
  }
45
14
 
46
15
  /** Run the native Google sign-in flow against the given Firebase Auth. Throws on failure. */
47
- export async function signInWithGoogleNative(auth: Auth): Promise<void> {
48
- if (!CLIENT_ID) throw new Error('VITE_GOOGLE_IOS_OAUTH_CLIENT_ID not configured');
49
- await kit.ready();
50
- if (kit.oauth.capability !== 'native') {
51
- throw new Error('kit.oauth unavailable — shell built without the "oauth" module');
52
- }
53
-
54
- const scheme = reversedClientId(CLIENT_ID);
55
- const redirectUri = `${scheme}:/oauth2redirect`;
56
- const verifier = base64Url(crypto.getRandomValues(new Uint8Array(32)));
57
- const challenge = base64Url(await sha256(verifier));
58
-
59
- const authUrl =
60
- 'https://accounts.google.com/o/oauth2/v2/auth?' +
61
- new URLSearchParams({
62
- client_id: CLIENT_ID,
63
- redirect_uri: redirectUri,
64
- response_type: 'code',
65
- scope: 'openid email profile',
66
- code_challenge: challenge,
67
- code_challenge_method: 'S256',
68
- }).toString();
69
-
70
- console.log('[auth] native Google sign-in via system browser');
71
- const { url } = await kit.oauth.authorize({ url: authUrl, callbackScheme: scheme });
72
-
73
- const code = new URL(url).searchParams.get('code');
74
- if (!code) throw new Error('OAuth callback missing authorization code');
75
-
76
- // iOS OAuth clients are "installed apps" — PKCE, no client secret.
77
- const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
78
- method: 'POST',
79
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
80
- body: new URLSearchParams({
81
- client_id: CLIENT_ID,
82
- code,
83
- code_verifier: verifier,
84
- grant_type: 'authorization_code',
85
- redirect_uri: redirectUri,
86
- }).toString(),
87
- });
88
- if (!tokenRes.ok) {
89
- const body = await tokenRes.text().catch(() => '');
90
- throw new Error(`token exchange failed: HTTP ${tokenRes.status} — ${body}`);
91
- }
92
- const { id_token } = await tokenRes.json();
93
- if (!id_token) throw new Error('token response missing id_token');
94
-
95
- await signInWithCredential(auth, GoogleAuthProvider.credential(id_token));
16
+ export function signInWithGoogleNative(auth: Auth): Promise<void> {
17
+ return getNativeProvider().signInWithGoogleNative(auth);
96
18
  }
@@ -1,32 +1,28 @@
1
- // Thin wrapper around @livx.cc/native-kit. In a plain browser the kit's WebAdapter wins the
2
- // handshake `kit.is.native === false` and every push capability reports 'none', so all the
3
- // push paths below no-op cleanly. Inside an appwrap native shell the AppwrapAdapter wins and
4
- // push becomes 'native'. We expose only what the client needs (readiness + push + visibility).
5
- import { kit } from '@livx.cc/native-kit';
6
- import type { PushMessage, PushToken } from '@livx.cc/native-kit';
1
+ // Push + native-readiness facade over the native capability seam (nativeProvider.ts). CE ships the
2
+ // web no-op provider, so every push path here is inert in a plain browser; an EE/native build swaps
3
+ // in a @livx.cc/native-kit-backed provider and the same calls light up. `isVisible` /
4
+ // `onVisibilityChange` are pure Page-Visibility API not native so they stay implemented here.
5
+ import { getNativeProvider, type NativePush, type PushMessage, type PushToken } from './nativeProvider';
7
6
 
8
7
  export type { PushMessage, PushToken };
9
8
 
10
- /** The native-kit push module (isomorphic; reports capability 'none' in a browser). */
11
- export const push = kit.push;
12
-
13
- let readyPromise: Promise<boolean> | null = null;
9
+ /** The push module (delegates to the active native provider; 'none' capability on web). */
10
+ export const push: NativePush = {
11
+ get capability() {
12
+ return getNativeProvider().push.capability;
13
+ },
14
+ register: () => getNativeProvider().push.register(),
15
+ requestPermission: () => getNativeProvider().push.requestPermission(),
16
+ onTap: (cb) => getNativeProvider().push.onTap(cb),
17
+ onMessage: (cb) => getNativeProvider().push.onMessage(cb),
18
+ };
14
19
 
15
20
  /**
16
- * Run the one-time `kit.ready()` handshake and resolve whether we're inside a native shell.
17
- * Idempotent and never rejects — a failed/timed-out handshake degrades to `false` (web).
21
+ * Run the one-time native-shell handshake and resolve whether we're inside a native shell.
22
+ * Idempotent and never rejects — on web it resolves `false`.
18
23
  */
19
24
  export function initNative(): Promise<boolean> {
20
- if (!readyPromise) {
21
- readyPromise = kit
22
- .ready()
23
- .then(() => kit.is.native)
24
- .catch((err) => {
25
- console.warn('[push] kit.ready() failed, treating as web', err);
26
- return false;
27
- });
28
- }
29
- return readyPromise;
25
+ return getNativeProvider().initNative();
30
26
  }
31
27
 
32
28
  /** Current page visibility (Page Visibility API). True when the tab/app is foregrounded. */
@@ -0,0 +1,88 @@
1
+ // The NATIVE capability seam — the imperative sibling of the client SLOTS seam (slots.tsx).
2
+ //
3
+ // CE core owns no native shell. Push, desktop-attention (Dock bounce/badge), and native Google
4
+ // OAuth all require a native runtime (@livx.cc/native-kit inside an appwrap shell), so that
5
+ // dependency + implementation live in the EE/native overlay, NOT here. CE ships the web NO-OP
6
+ // provider below and reads capabilities exclusively through `getNativeProvider()` — so the built CE
7
+ // bundle contains zero native-kit code, exactly like the empty client-slot set.
8
+ //
9
+ // An EE/native build fills the seam at its composition root (before first render):
10
+ // registerNativeProvider(nativeKitProvider) // main.ee.tsx
11
+ // consuming CE as a library. No CE file is shadowed; the provider is swapped at runtime, so every
12
+ // CE consumer (`usePush`, `useUnread`, `App`, `firebase`) keeps its imports and lights up natively.
13
+ import type { Auth } from 'firebase/auth';
14
+
15
+ /** A push message delivered to the app (foreground) or via a notification tap. */
16
+ export interface PushMessage {
17
+ title?: string;
18
+ body?: string;
19
+ data?: Record<string, unknown>;
20
+ }
21
+
22
+ /** The device push registration returned by `push.register()`. */
23
+ export interface PushToken {
24
+ platform: string;
25
+ token: string;
26
+ topic?: string;
27
+ }
28
+
29
+ export type PushPermission = 'granted' | 'denied' | 'default';
30
+
31
+ /** Push surface consumed by `usePush`. `capability !== 'native'` ⇒ consumers skip every push path. */
32
+ export interface NativePush {
33
+ capability: string;
34
+ register(): Promise<PushToken>;
35
+ requestPermission(): Promise<PushPermission>;
36
+ onTap(cb: (m: PushMessage) => void): () => void;
37
+ onMessage(cb: (m: PushMessage) => void): () => void;
38
+ }
39
+
40
+ /**
41
+ * Everything the CE client needs from a native runtime. CE provides the web no-op default; the EE
42
+ * overlay registers a @livx.cc/native-kit-backed implementation. Loose/local types only — never
43
+ * import a native-kit type into the core, or the dependency leaks back across the seam.
44
+ */
45
+ export interface NativeProvider {
46
+ /** One-time native-shell handshake → true inside a native shell, false on web. Never rejects. */
47
+ initNative(): Promise<boolean>;
48
+ push: NativePush;
49
+ /** One-time detection of the appwrap DESKTOP shell (Dock bounce/badge available). Never rejects. */
50
+ initDesktopAttention(): Promise<boolean>;
51
+ isDesktopShell(): boolean;
52
+ requestDesktopAttention(blocking?: boolean): void;
53
+ setDesktopBadge(count: number): void;
54
+ /** True only inside a native shell with a configured native Google OAuth client. */
55
+ canUseNativeGoogleSignIn(): Promise<boolean>;
56
+ /** Run the native (system-browser) Google sign-in against the given Firebase Auth. */
57
+ signInWithGoogleNative(auth: Auth): Promise<void>;
58
+ }
59
+
60
+ /** CE default: no native shell — every capability is an inert web no-op. */
61
+ const webProvider: NativeProvider = {
62
+ initNative: () => Promise.resolve(false),
63
+ push: {
64
+ capability: 'none',
65
+ register: () => Promise.reject(new Error('push unavailable on web')),
66
+ requestPermission: () => Promise.resolve('denied'),
67
+ onTap: () => () => {},
68
+ onMessage: () => () => {},
69
+ },
70
+ initDesktopAttention: () => Promise.resolve(false),
71
+ isDesktopShell: () => false,
72
+ requestDesktopAttention: () => {},
73
+ setDesktopBadge: () => {},
74
+ canUseNativeGoogleSignIn: () => Promise.resolve(false),
75
+ signInWithGoogleNative: () => Promise.reject(new Error('native Google sign-in unavailable on web')),
76
+ };
77
+
78
+ let active: NativeProvider = webProvider;
79
+
80
+ /** Fill the native seam (EE/native composition root). Last registration wins. */
81
+ export function registerNativeProvider(provider: NativeProvider): void {
82
+ active = provider;
83
+ }
84
+
85
+ /** The active native provider — the CE web no-op unless an overlay registered one. */
86
+ export function getNativeProvider(): NativeProvider {
87
+ return active;
88
+ }
package/src/index.ts CHANGED
@@ -51,6 +51,10 @@ export class ShragaOptions {
51
51
  dataDir?: string;
52
52
  /** Auth backend. 'local' (default) = username/password; 'firebase' needs the firebase add-on. */
53
53
  authProvider?: 'local' | 'firebase';
54
+ /** Directory of the built client to serve (index.html + assets). Default = shraga's shipped
55
+ * `dist/client`. A consumer shipping its own UI (e.g. the EE client build) points this at its
56
+ * own dist. Precedence: this option > SHRAGA_CLIENT_DIR env > default. */
57
+ clientDir?: string;
54
58
  /** Passive mode — HTTP serving only, no schedulers/consumers/background writers (standby twins). */
55
59
  passive?: boolean;
56
60
  /** Install process SIGTERM/SIGINT handlers (default true — the standalone server/CLI wants them).
@@ -106,6 +110,7 @@ class Shraga implements ShragaInstance {
106
110
  if (o.port != null) process.env.PORT = String(o.port);
107
111
  if (o.dataDir != null) process.env.DATA_DIR = o.dataDir;
108
112
  if (o.authProvider != null) process.env.AUTH_PROVIDER = o.authProvider;
113
+ if (o.clientDir != null) process.env.SHRAGA_CLIENT_DIR = o.clientDir;
109
114
  if (o.passive != null) process.env.SHRAGA_PASSIVE = o.passive ? '1' : '0';
110
115
  if (o.installSignalHandlers === false) process.env.SHRAGA_INSTALL_SIGNALS = '0';
111
116
  if (o.runtimeRegistration != null) process.env.SHRAGA_RUNTIME_REGISTRATION = o.runtimeRegistration ? '1' : '0';
@@ -174,6 +179,7 @@ export function fromEnv(): Partial<ShragaOptions> {
174
179
  port: process.env.PORT ? Number(process.env.PORT) : undefined,
175
180
  dataDir: process.env.DATA_DIR || undefined,
176
181
  authProvider: (process.env.AUTH_PROVIDER as 'local' | 'firebase' | undefined) || undefined,
182
+ clientDir: process.env.SHRAGA_CLIENT_DIR || undefined,
177
183
  passive: passive === '1' || passive === 'true' ? true : undefined,
178
184
  runtimeRegistration: runtimeReg === '1' || runtimeReg === 'true' ? true : undefined,
179
185
  };
@@ -132,7 +132,11 @@ backfillSessionVisibility(({ name }) => {
132
132
  });
133
133
 
134
134
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
135
- const distPath = path.resolve(__dirname, '../../dist/client');
135
+ // The client build to serve. Default = shraga's shipped dist/client; a consumer shipping its own UI
136
+ // (e.g. the EE client) sets SHRAGA_CLIENT_DIR (via the clientDir option or env) to its own dist.
137
+ const distPath = process.env.SHRAGA_CLIENT_DIR
138
+ ? path.resolve(process.env.SHRAGA_CLIENT_DIR)
139
+ : path.resolve(__dirname, '../../dist/client');
136
140
 
137
141
  const app = express();
138
142
  app.use(express.json({
@@ -197,17 +201,22 @@ app.put('/api/sessions/:id/directives', requireAuth, (req, res) => {
197
201
  if (!meta) return void res.status(404).json({ error: 'not found' });
198
202
  if (!isSessionVisibleTo(meta, user.uid, user.isOwner, user.email)) return void res.status(404).json({ error: 'not found' });
199
203
  // thinking is an untrusted request field; type it to the valid set (invalid strings fall through
200
- // the `|| undefined` below). voiceModel/thinkModel are add-on passthrough keys the core doesn't name.
201
- const body = (req.body ?? {}) as { engine?: string; model?: string; turns?: number; thinking?: 'enabled' | 'adaptive' | 'disabled'; voiceModel?: string; thinkModel?: string | false };
204
+ // the `|| undefined` below). Keys the core doesn't own (an add-on's, e.g. voice model directives)
205
+ // pass through OPAQUELY the core names none of them: '' clears, `false` is preserved, else stored.
206
+ const CORE_KEYS = new Set(['engine', 'model', 'turns', 'thinking']);
207
+ const body = (req.body ?? {}) as Record<string, unknown> & { engine?: string; model?: string; turns?: number; thinking?: 'enabled' | 'adaptive' | 'disabled' };
208
+ const passthrough: Record<string, unknown> = {};
209
+ for (const [k, v] of Object.entries(body)) {
210
+ if (CORE_KEYS.has(k)) continue;
211
+ passthrough[k] = v === '' ? undefined : v; // '' = unset → fall back to default; `false` preserved (e.g. a tier off)
212
+ }
202
213
  const next = {
203
214
  ...meta.directives,
204
215
  ...(body.engine !== undefined ? { engine: body.engine || undefined } : {}),
205
216
  ...(body.model !== undefined ? { model: body.model || undefined } : {}),
206
217
  ...(body.turns !== undefined ? { turns: body.turns } : {}),
207
218
  ...(body.thinking !== undefined ? { thinking: body.thinking || undefined } : {}),
208
- ...(body.voiceModel !== undefined ? { voiceModel: body.voiceModel || undefined } : {}),
209
- // thinkModel: false = explicitly off (Think tier disabled); '' = unset → fall back to default.
210
- ...(body.thinkModel !== undefined ? { thinkModel: body.thinkModel === false ? false : (body.thinkModel || undefined) } : {}),
219
+ ...passthrough,
211
220
  };
212
221
  setSessionDirectives(sid, next);
213
222
  res.json({ directives: next });
@@ -806,9 +815,10 @@ function sendUnreadSync(ws: WebSocket, uid: string) {
806
815
 
807
816
  // ── Sidecar WebSocket proxy ─────────────────────────────────────────────────
808
817
 
809
- // Core sidecar WS proxy routes (url-prefix → localhost port). Feature-contributed routes
810
- // (an add-on's prefix → its own daemon port) are folded in after feature registration below.
811
- const WS_PROXY_ROUTES: Record<string, number> = { cursor: 3845 };
818
+ // Sidecar WS proxy routes (url-prefix → localhost port). CE owns NONE — every route is an add-on's
819
+ // own daemon (its prefix → its port), folded in from the `sidecarRoutes` feature seam after feature
820
+ // registration below (see `Object.assign(WS_PROXY_ROUTES, collectSidecarRoutes())`).
821
+ const WS_PROXY_ROUTES: Record<string, number> = {};
812
822
 
813
823
  function resolveSidecarPort(urlPath: string): number | null {
814
824
  const prefix = urlPath.split('/').filter(Boolean)[0];
@@ -919,7 +929,7 @@ function broadcast(data: object, exclude?: WebSocket) {
919
929
  }
920
930
  }
921
931
 
922
- setBroadcaster(broadcast); // let session-bus push async events (e.g. an add-on's background re-voice) to clients
932
+ setBroadcaster(broadcast); // let session-bus push async events (e.g. an add-on's background worker output) to clients
923
933
  ensureWorkspaceDir();
924
934
  watchWorkspace((event) => broadcast({ type: 'workspace_change', ...event }));
925
935
  if (!PASSIVE) { scheduler.start(broadcast); startEventDispatcher(); }
@@ -1000,7 +1010,7 @@ app.post('/api/data-sync/webhook', async (req, res) => {
1000
1010
  res.sendStatus(200);
1001
1011
  });
1002
1012
 
1003
- async function runStream(ws: WebSocket, session: WsSession, sid: string, promptText: string, attachments: AttachmentMeta[] | undefined, mcpServers: McpConfig, isSteerRestart = false, voiceMode = false, conversationReset = false, turnHints?: Record<string, unknown>) {
1013
+ async function runStream(ws: WebSocket, session: WsSession, sid: string, promptText: string, attachments: AttachmentMeta[] | undefined, mcpServers: McpConfig, isSteerRestart = false, conversationReset = false, turnHints?: Record<string, unknown>) {
1004
1014
  const abortController = new AbortController();
1005
1015
  if (!isSteerRestart) {
1006
1016
  if (!acquireSessionLock(sid, 'web', abortController)) {
@@ -1017,12 +1027,13 @@ async function runStream(ws: WebSocket, session: WsSession, sid: string, promptT
1017
1027
  send(ws, { type: 'session_busy', sessionId: sid, busy: true });
1018
1028
  broadcast({ type: 'session_busy', sessionId: sid, busy: true }, ws);
1019
1029
 
1020
- // Voice mode is unattended nobody is watching the UI to click Allow, so auto-approve for the whole run.
1021
- const unattended = voiceMode;
1030
+ // An unattended turn (e.g. a voice-originated one) has nobody watching the UI to click Allow, so
1031
+ // auto-approve for the whole run. Generic hint off the opaque bag — the core names no add-on concept.
1032
+ const unattended = turnHints?.unattended === true;
1022
1033
 
1023
1034
  const onPermissionRequest: PermissionHandler = (id, tool, input) => {
1024
1035
  if (session.autoApprove || unattended) {
1025
- console.log(`[ws] Auto-approved ${tool} id=${id}${unattended ? ' (voice mode)' : ''}`);
1036
+ console.log(`[ws] Auto-approved ${tool} id=${id}${unattended ? ' (unattended)' : ''}`);
1026
1037
  return Promise.resolve({ allow: true });
1027
1038
  }
1028
1039
  if (ws.readyState !== WebSocket.OPEN) {
@@ -1094,7 +1105,6 @@ async function runStream(ws: WebSocket, session: WsSession, sid: string, promptT
1094
1105
  userName: session.email.split('@')[0],
1095
1106
  mcpServers,
1096
1107
  abortController,
1097
- voiceMode,
1098
1108
  conversationReset,
1099
1109
  turnHints,
1100
1110
  context: { source: 'web', user: session.email },
@@ -1102,7 +1112,7 @@ async function runStream(ws: WebSocket, session: WsSession, sid: string, promptT
1102
1112
  onUserQuestion,
1103
1113
  onDestructiveApproval: (id, tool, input) => {
1104
1114
  if (unattended) {
1105
- console.log(`[ws] Auto-approved destructive ${tool} id=${id} (voice mode)`);
1115
+ console.log(`[ws] Auto-approved destructive ${tool} id=${id} (unattended)`);
1106
1116
  return Promise.resolve({ allow: true });
1107
1117
  }
1108
1118
  return new Promise<{ allow: boolean }>((resolve) => {
@@ -1222,8 +1232,8 @@ async function runStream(ws: WebSocket, session: WsSession, sid: string, promptT
1222
1232
  if (steerText) {
1223
1233
  appendMessage(sid, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'text', text: steerText }], channel: 'web', senderName: session.email.split('@')[0] });
1224
1234
  console.log(`[ws] Restarting stream with steer for ${sid.slice(0, 8)}`);
1225
- // Preserve voice/unattended mode across a steer-restart, else auto-approve is lost mid-turn and prompts hang.
1226
- await runStream(ws, session, sid, steerText, undefined, mcpServers, true, voiceMode);
1235
+ // Preserve the turn hints (e.g. unattended auto-approve) across a steer-restart, else auto-approve is lost mid-turn and prompts hang.
1236
+ await runStream(ws, session, sid, steerText, undefined, mcpServers, true, false, turnHints);
1227
1237
  return;
1228
1238
  }
1229
1239
 
@@ -1375,11 +1385,14 @@ function handleConnection(ws: WebSocket, session: WsSession) {
1375
1385
  if (!wantsFork && (session.busySessions.has(sid) || isSessionLocked(sid))) return send(ws, { type: 'error', message: 'Already processing a request', sessionId: sid });
1376
1386
  if (!wantsFork) session.busySessions.add(sid);
1377
1387
  const mcpServers = getMcpConfig(session.uid);
1378
- // Voice greeting: a synthetic, agent-first opener. Strip the marker and DON'T
1379
- // persist it as a user message the spoken greeting is the agent's reply, not a user turn.
1380
- const GREETING_SENTINEL = '__VOICE_GREETING__';
1381
- const isGreeting = (msg.text ?? '').startsWith(GREETING_SENTINEL);
1382
- const promptText = isGreeting ? (msg.text ?? '').slice(GREETING_SENTINEL.length).trimStart() : (msg.text ?? '');
1388
+ // Opaque per-send bag from the client's send-options slot. The core forwards it verbatim to the
1389
+ // turn-context/engine seams and interprets no add-on key (a plain object only never an array/primitive).
1390
+ const turnHints = msg.turnHints && typeof msg.turnHints === 'object' && !Array.isArray(msg.turnHints)
1391
+ ? (msg.turnHints as Record<string, unknown>) : undefined;
1392
+ // Ephemeral user turn (generic hint): a synthetic, add-on-originated opener (e.g. a voice greeting).
1393
+ // Run it, but DON'T persist it as a user message — the reply is the real turn, not a user turn.
1394
+ const ephemeralUser = turnHints?.ephemeralUser === true;
1395
+ const promptText = msg.text ?? '';
1383
1396
  session.lastSessionId = sid;
1384
1397
  session.viewingSessionId = sid;
1385
1398
  session.focused = true;
@@ -1428,14 +1441,10 @@ function handleConnection(ws: WebSocket, session: WsSession) {
1428
1441
  ? { type: 'image' as const, src: a.url }
1429
1442
  : { type: 'file' as const, src: a.url, name: a.name, mimeType: a.mimeType }
1430
1443
  );
1431
- if (!isGreeting) appendMessage(sid, { id: crypto.randomUUID(), role: 'user', blocks: [...attBlocks, { type: 'text', text: promptText }], channel: 'web', senderName: session.email.split('@')[0] });
1444
+ if (!ephemeralUser) appendMessage(sid, { id: crypto.randomUUID(), role: 'user', blocks: [...attBlocks, { type: 'text', text: promptText }], channel: 'web', senderName: session.email.split('@')[0] });
1432
1445
 
1433
1446
  const wasReset = typeof msg.truncateAt === 'number' && msg.truncateAt >= 0;
1434
- // Opaque per-send bag from the client's send-options slot. The core forwards it verbatim to the
1435
- // turn-context seam and interprets no key of it (a plain object only — never an array/primitive).
1436
- const turnHints = msg.turnHints && typeof msg.turnHints === 'object' && !Array.isArray(msg.turnHints)
1437
- ? (msg.turnHints as Record<string, unknown>) : undefined;
1438
- await runStream(ws, session, sid, promptText, attachments, mcpServers, false, !!msg.voiceMode, wasReset, turnHints);
1447
+ await runStream(ws, session, sid, promptText, attachments, mcpServers, false, wasReset, turnHints);
1439
1448
  }
1440
1449
 
1441
1450
  if (msg.type === 'interruption_marker') {
@@ -1447,7 +1456,7 @@ function handleConnection(ws: WebSocket, session: WsSession) {
1447
1456
  appendMessage(sid, {
1448
1457
  id: crypto.randomUUID(),
1449
1458
  role: 'system',
1450
- blocks: [{ type: 'text', text: `[User interrupted voice playback — only heard up to: "${tail}"]` }],
1459
+ blocks: [{ type: 'text', text: `[User interrupted playback — only heard up to: "${tail}"]` }],
1451
1460
  });
1452
1461
  }
1453
1462
  }
@@ -71,11 +71,11 @@ export type WsEvent =
71
71
  | { type: 'thinking_delta'; text: string }
72
72
  | { type: 'done'; sessionId: string; stopReason?: 'end_turn' | 'max_turns_reached' | (string & {}); builtinHandled?: boolean }
73
73
  | { type: 'model_resolved'; sessionId: string; model: string }
74
- | { type: 'duplex_task'; taskId: string; status: 'started' | 'progress' | 'done' | 'error' | 'cancelled' | 'ask'; label?: string; text?: string; tier?: string }
75
- | { type: 'duplex_revoice'; text: string }
76
- | { type: 'duplex_result'; taskId: string; text: string; label?: string; tier?: string }
77
74
  | { type: 'error'; message: string }
78
75
  | { type: 'stats'; sample: { t: number; cpu: number; mem: number; load: number } };
76
+ // Add-on engines/features emit their OWN events (e.g. a duplex voice brain's `duplex_*`) through the
77
+ // object-typed `emitToSession()` bus (session-bus.ts) — NOT this union. So the core names none of them
78
+ // here, yet forwards them verbatim to clients. Keep this union the closed set of core-owned events.
79
79
 
80
80
  // ── Stream chat ─────────────────────────────────────────────────────────────
81
81
 
@@ -195,7 +195,6 @@ export async function* streamChat(opts: {
195
195
  onPermissionRequest?: PermissionHandler;
196
196
  onDestructiveApproval?: PermissionHandler;
197
197
  onUserQuestion?: QuestionHandler;
198
- voiceMode?: boolean;
199
198
  conversationReset?: boolean;
200
199
  /** Opaque per-send bag from the client. Never interpreted here — handed to the turn-context seam,
201
200
  * where an add-on's contributor reads its own keys. */
@@ -350,7 +349,7 @@ export async function* streamChat(opts: {
350
349
  onPermissionRequest: opts.onPermissionRequest,
351
350
  onDestructiveApproval: opts.onDestructiveApproval,
352
351
  onUserQuestion: opts.onUserQuestion,
353
- voiceMode: opts.voiceMode,
352
+ turnHints: opts.turnHints,
354
353
  conversationReset: opts.conversationReset,
355
354
  context: opts.context,
356
355
  directives,