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.
- package/dist/client/assets/index-ChElotX8.js +1936 -0
- package/dist/client/index.html +1 -1
- package/package.json +1 -2
- package/src/cli.ts +15 -0
- package/src/client/lib/desktopAttention.ts +13 -60
- package/src/client/lib/googleAuthNative.ts +13 -91
- package/src/client/lib/native.ts +18 -22
- package/src/client/lib/nativeProvider.ts +88 -0
- package/src/index.ts +6 -0
- package/src/server/boot.ts +39 -30
- package/src/server/claude.ts +4 -5
- package/src/server/engine/claude-code.ts +5 -2
- package/src/server/engine/types.ts +3 -1
- package/src/server/ingress-router.ts +90 -0
- package/src/server/prompt-suffix.ts +38 -0
- package/src/server/sessions.ts +2 -0
- package/dist/client/assets/index-FM42KQNo.js +0 -1940
- package/src/server/engine/shared.ts +0 -47
- package/src/server/voice-provider.ts +0 -74
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 —
|
|
57
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1
|
+
// Native Google sign-in facade over the native capability seam (nativeProvider.ts).
|
|
2
2
|
//
|
|
3
|
-
// Google rejects OAuth
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
48
|
-
|
|
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
|
}
|
package/src/client/lib/native.ts
CHANGED
|
@@ -1,32 +1,28 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
import {
|
|
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
|
|
11
|
-
export const push =
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
17
|
-
* Idempotent and never rejects —
|
|
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
|
-
|
|
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
|
};
|
package/src/server/boot.ts
CHANGED
|
@@ -132,7 +132,11 @@ backfillSessionVisibility(({ name }) => {
|
|
|
132
132
|
});
|
|
133
133
|
|
|
134
134
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
135
|
-
|
|
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).
|
|
201
|
-
|
|
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
|
-
...
|
|
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
|
-
//
|
|
810
|
-
// (
|
|
811
|
-
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
1021
|
-
|
|
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 ? ' (
|
|
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} (
|
|
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
|
|
1226
|
-
await runStream(ws, session, sid, steerText, undefined, mcpServers, true,
|
|
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
|
-
//
|
|
1379
|
-
//
|
|
1380
|
-
const
|
|
1381
|
-
|
|
1382
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
1459
|
+
blocks: [{ type: 'text', text: `[User interrupted playback — only heard up to: "${tail}"]` }],
|
|
1451
1460
|
});
|
|
1452
1461
|
}
|
|
1453
1462
|
}
|
package/src/server/claude.ts
CHANGED
|
@@ -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
|
-
|
|
352
|
+
turnHints: opts.turnHints,
|
|
354
353
|
conversationReset: opts.conversationReset,
|
|
355
354
|
context: opts.context,
|
|
356
355
|
directives,
|