shraga 0.0.3 → 0.1.2
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/README.md +82 -27
- package/defaults/agents/summarizer.md +16 -0
- package/defaults/agents/trace-extractor.md +84 -0
- package/defaults/bin/claude +45 -0
- package/defaults/bin/claude-revive +17 -0
- package/defaults/extensions/README.md +70 -0
- package/defaults/extensions/selftest.ext.ts +43 -0
- package/defaults/extensions/stripe-webhook.ext.ts +58 -0
- package/defaults/gmail-triage-prompt.md +42 -0
- package/defaults/scripts/README +4 -0
- package/defaults/scripts/agent-once.ts +67 -0
- package/defaults/scripts/backfill-slack-usernames.ts +82 -0
- package/defaults/scripts/notifier-throttle.ts +44 -0
- package/defaults/scripts/summarize-conversations.ts +5 -0
- package/defaults/shraga.config.ts +29 -0
- package/defaults/skills/add-skill.md +14 -0
- package/defaults/skills/artifacts.md +116 -0
- package/defaults/skills/code-review.md +26 -0
- package/defaults/skills/communications.md +54 -0
- package/defaults/skills/context-audit.md +87 -0
- package/defaults/skills/debug.md +10 -0
- package/defaults/skills/garden.md +179 -0
- package/defaults/skills/github-contributor.md +35 -0
- package/defaults/skills/identity.md +30 -0
- package/defaults/skills/mcp-server.md +62 -0
- package/defaults/skills/mcps-sync.md +105 -0
- package/defaults/skills/plan.md +9 -0
- package/defaults/skills/platform.md +177 -0
- package/defaults/skills/reconcile.md +239 -0
- package/defaults/skills/scheduler.md +192 -0
- package/defaults/skills/self-aware.md +136 -0
- package/defaults/skills/shraga-know.md +333 -0
- package/defaults/skills/stripe.md +55 -0
- package/defaults/skills/write-tests.md +10 -0
- package/defaults/skills-defaults.json +1 -0
- package/defaults/system-prompt.md +46 -0
- package/defaults/workspace/context.md +28 -0
- package/defaults/workspace.md +50 -0
- package/defaults/zdotdir/.gitignore +8 -0
- package/defaults/zdotdir/.zlogin +3 -0
- package/defaults/zdotdir/.zprofile +1 -0
- package/defaults/zdotdir/.zshenv +4 -0
- package/defaults/zdotdir/.zshrc +3 -0
- package/dist/client/assets/index-BoHttkMt.js +1940 -0
- package/dist/client/assets/index-DdibEb2O.css +10 -0
- package/dist/client/index.html +22 -0
- package/package.json +59 -14
- package/src/cli.ts +71 -46
- package/src/client/App.tsx +510 -0
- package/src/client/components/ArtifactCard.tsx +26 -0
- package/src/client/components/ArtifactPanel.tsx +138 -0
- package/src/client/components/AuthedImage.tsx +85 -0
- package/src/client/components/AutocompleteTextarea.tsx +149 -0
- package/src/client/components/ChatView.tsx +866 -0
- package/src/client/components/CliAuthConsent.tsx +98 -0
- package/src/client/components/ConfigPanel.tsx +328 -0
- package/src/client/components/ConversationHeader.tsx +156 -0
- package/src/client/components/ConversationPane.tsx +277 -0
- package/src/client/components/LoginPage.tsx +81 -0
- package/src/client/components/MachineStats.tsx +77 -0
- package/src/client/components/McpManager.tsx +209 -0
- package/src/client/components/MessageInput.tsx +263 -0
- package/src/client/components/OAuthConsent.tsx +103 -0
- package/src/client/components/SchedulesManager.tsx +99 -0
- package/src/client/components/Sidebar.tsx +235 -0
- package/src/client/components/SkillsManager.tsx +280 -0
- package/src/client/components/SmartChart.tsx +167 -0
- package/src/client/components/Toast.tsx +54 -0
- package/src/client/components/WorkspaceTree.tsx +313 -0
- package/src/client/components/ZoomableImage.tsx +123 -0
- package/src/client/components/artifact-presets.ts +10 -0
- package/src/client/components/schedules/ScheduleEditor.tsx +264 -0
- package/src/client/components/schedules/ScheduleList.tsx +271 -0
- package/src/client/components/ui/accordion.tsx +50 -0
- package/src/client/components/ui/button.tsx +43 -0
- package/src/client/components/ui/dialog.tsx +82 -0
- package/src/client/components/ui/input.tsx +19 -0
- package/src/client/components/ui/scroll-area.tsx +39 -0
- package/src/client/components/ui/textarea.tsx +18 -0
- package/src/client/globals.css +51 -0
- package/src/client/hooks/useAgentSocket.ts +79 -0
- package/src/client/hooks/useArtifacts.ts +89 -0
- package/src/client/hooks/useAuth.ts +127 -0
- package/src/client/hooks/useConversation.ts +412 -0
- package/src/client/hooks/useDarkMode.ts +57 -0
- package/src/client/hooks/useIsMobile.ts +23 -0
- package/src/client/hooks/usePush.ts +127 -0
- package/src/client/hooks/useSchedules.ts +73 -0
- package/src/client/hooks/useUnread.ts +238 -0
- package/src/client/lib/desktopAttention.ts +75 -0
- package/src/client/lib/firebase.ts +32 -0
- package/src/client/lib/googleAuthNative.ts +94 -0
- package/src/client/lib/native.ts +43 -0
- package/src/client/lib/schedule-types.ts +34 -0
- package/src/client/lib/sessionApi.ts +58 -0
- package/src/client/lib/slots.tsx +79 -0
- package/src/client/lib/storage.ts +39 -0
- package/src/client/lib/utils.ts +26 -0
- package/src/client/lib/workspaceContext.tsx +54 -0
- package/src/client/lib/ws.ts +203 -0
- package/src/client/main.tsx +14 -0
- package/src/mcp-stdio-bridge.ts +70 -0
- package/src/scripts/summarize-conversations.ts +5 -0
- package/src/scripts/typecheck.ts +43 -0
- package/src/server/agents.ts +54 -0
- package/src/server/api-keys.ts +63 -0
- package/src/server/artifacts/artifacts.export.ts +85 -0
- package/src/server/artifacts/artifacts.handler.ts +93 -0
- package/src/server/artifacts/artifacts.routes.ts +43 -0
- package/src/server/artifacts/artifacts.service.ts +100 -0
- package/src/server/artifacts/artifacts.types.ts +31 -0
- package/src/server/auth.ts +262 -0
- package/src/server/claude.ts +394 -0
- package/src/server/commands.ts +21 -0
- package/src/server/contacts.ts +177 -0
- package/src/server/conversation-summarizer.ts +204 -0
- package/src/server/data-sync.ts +664 -0
- package/src/server/directives.ts +91 -0
- package/src/server/engine/claude-code.ts +514 -0
- package/src/server/engine/index.ts +41 -0
- package/src/server/engine/registry.ts +21 -0
- package/src/server/engine/shared.ts +47 -0
- package/src/server/engine/types.ts +48 -0
- package/src/server/env-resolve.ts +71 -0
- package/src/server/env-sanitize.ts +9 -0
- package/src/server/events/bus.ts +29 -0
- package/src/server/events/dispatcher.ts +48 -0
- package/src/server/events/routes.ts +19 -0
- package/src/server/events/types.ts +9 -0
- package/src/server/extensions.ts +101 -0
- package/src/server/features.ts +109 -0
- package/src/server/file-inject.ts +45 -0
- package/src/server/hooks.ts +142 -0
- package/src/server/idempotency.ts +25 -0
- package/src/server/index.ts +1715 -0
- package/src/server/integrity-audit.ts +132 -0
- package/src/server/mcp-catalog.ts +70 -0
- package/src/server/mcp-oauth.ts +198 -0
- package/src/server/mcp-progress.ts +45 -0
- package/src/server/mcp-server.ts +456 -0
- package/src/server/mcp-sidecar.ts +87 -0
- package/src/server/mcp.ts +291 -0
- package/src/server/model-aliases.ts +76 -0
- package/src/server/paths.ts +24 -0
- package/src/server/polls.ts +175 -0
- package/src/server/push/apns.ts +113 -0
- package/src/server/push/fcm.ts +108 -0
- package/src/server/push/push.ts +66 -0
- package/src/server/push/store.ts +84 -0
- package/src/server/push/triggers.ts +99 -0
- package/src/server/scheduler/builtins.ts +157 -0
- package/src/server/scheduler/engine.ts +432 -0
- package/src/server/scheduler/index.ts +4 -0
- package/src/server/scheduler/runner.ts +334 -0
- package/src/server/scheduler/storage.ts +98 -0
- package/src/server/scheduler/timing.ts +70 -0
- package/src/server/scheduler/types.ts +62 -0
- package/src/server/sdk-utils.ts +45 -0
- package/src/server/seed.ts +174 -0
- package/src/server/session-bus.ts +18 -0
- package/src/server/sessions.ts +559 -0
- package/src/server/shraga-config.ts +167 -0
- package/src/server/skills.ts +372 -0
- package/src/server/slack/api.ts +37 -0
- package/src/server/slack/bot.ts +391 -0
- package/src/server/slack/context-cache.ts +42 -0
- package/src/server/slack/feature.ts +59 -0
- package/src/server/slack/mention-rewrite.ts +59 -0
- package/src/server/slack/oauth.ts +102 -0
- package/src/server/slack/questions.ts +112 -0
- package/src/server/slack/sessions.ts +139 -0
- package/src/server/stats.ts +106 -0
- package/src/server/summarize.ts +11 -0
- package/src/server/turn-context.ts +61 -0
- package/src/server/unclaw-config.ts +19 -0
- package/src/server/unread.ts +79 -0
- package/src/server/user-context.ts +33 -0
- package/src/server/vendor-sync.ts +52 -0
- package/src/server/voice-provider.ts +74 -0
- package/src/server/workspace.ts +249 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import type { Schedule } from '@/lib/schedule-types';
|
|
3
|
+
|
|
4
|
+
async function api<T>(path: string, getToken: () => Promise<string | null>, init?: RequestInit): Promise<T> {
|
|
5
|
+
const token = await getToken();
|
|
6
|
+
const res = await fetch(path, {
|
|
7
|
+
...init,
|
|
8
|
+
headers: { Authorization: `Bearer ${token ?? ''}`, 'Content-Type': 'application/json', ...init?.headers },
|
|
9
|
+
});
|
|
10
|
+
if (!res.ok) {
|
|
11
|
+
const body = await res.json().catch(() => ({}));
|
|
12
|
+
throw new Error(body.error || `${res.status} ${res.statusText}`);
|
|
13
|
+
}
|
|
14
|
+
return res.json();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useSchedules(getToken: () => Promise<string | null>, enabled: boolean, refreshKey = 0) {
|
|
18
|
+
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
|
19
|
+
const [serverRunningIds, setServerRunningIds] = useState<Set<string>>(new Set());
|
|
20
|
+
const [loading, setLoading] = useState(false);
|
|
21
|
+
const [error, setError] = useState<string | null>(null);
|
|
22
|
+
|
|
23
|
+
const refresh = useCallback(async () => {
|
|
24
|
+
setLoading(true);
|
|
25
|
+
setError(null);
|
|
26
|
+
try {
|
|
27
|
+
const data = await api<{ schedules: Schedule[]; runningIds: string[] }>('/api/schedules', getToken);
|
|
28
|
+
setSchedules(data.schedules);
|
|
29
|
+
setServerRunningIds(new Set(data.runningIds));
|
|
30
|
+
} catch (e: any) {
|
|
31
|
+
setError(e.message);
|
|
32
|
+
} finally {
|
|
33
|
+
setLoading(false);
|
|
34
|
+
}
|
|
35
|
+
}, [getToken]);
|
|
36
|
+
|
|
37
|
+
useEffect(() => { if (enabled) refresh(); }, [enabled, refresh, refreshKey]);
|
|
38
|
+
|
|
39
|
+
const create = useCallback(async (s: Partial<Schedule>) => {
|
|
40
|
+
const created = await api<Schedule>('/api/schedules', getToken, { method: 'POST', body: JSON.stringify(s) });
|
|
41
|
+
await refresh();
|
|
42
|
+
return created;
|
|
43
|
+
}, [getToken, refresh]);
|
|
44
|
+
|
|
45
|
+
const update = useCallback(async (id: string, s: Partial<Schedule>) => {
|
|
46
|
+
const updated = await api<Schedule>(`/api/schedules/${id}`, getToken, { method: 'PUT', body: JSON.stringify(s) });
|
|
47
|
+
await refresh();
|
|
48
|
+
return updated;
|
|
49
|
+
}, [getToken, refresh]);
|
|
50
|
+
|
|
51
|
+
const remove = useCallback(async (id: string) => {
|
|
52
|
+
await api(`/api/schedules/${id}`, getToken, { method: 'DELETE' });
|
|
53
|
+
await refresh();
|
|
54
|
+
}, [getToken, refresh]);
|
|
55
|
+
|
|
56
|
+
const toggle = useCallback(async (id: string, en: boolean) => {
|
|
57
|
+
await api(`/api/schedules/${id}/toggle`, getToken, { method: 'POST', body: JSON.stringify({ enabled: en }) });
|
|
58
|
+
await refresh();
|
|
59
|
+
}, [getToken, refresh]);
|
|
60
|
+
|
|
61
|
+
const runNow = useCallback(async (id: string, override?: string) => {
|
|
62
|
+
const result = await api<{ sessionId: string }>(`/api/schedules/${id}/run`, getToken, { method: 'POST', body: JSON.stringify(override ? { override } : {}) });
|
|
63
|
+
await refresh();
|
|
64
|
+
return result.sessionId;
|
|
65
|
+
}, [getToken, refresh]);
|
|
66
|
+
|
|
67
|
+
const cancelRun = useCallback(async (id: string) => {
|
|
68
|
+
await api(`/api/schedules/${id}/cancel`, getToken, { method: 'POST' });
|
|
69
|
+
await refresh();
|
|
70
|
+
}, [getToken, refresh]);
|
|
71
|
+
|
|
72
|
+
return { schedules, serverRunningIds, loading, error, refresh, create, update, remove, toggle, runNow, cancelRun };
|
|
73
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { AgentSocket, ServerEvent } from '@/lib/ws';
|
|
3
|
+
import { requestDesktopAttention, setDesktopBadge } from '@/lib/desktopAttention';
|
|
4
|
+
|
|
5
|
+
export interface UnreadSession {
|
|
6
|
+
count: number;
|
|
7
|
+
preview: string;
|
|
8
|
+
source: 'response' | 'proactive' | 'schedule';
|
|
9
|
+
title?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ToastItem {
|
|
13
|
+
id: string;
|
|
14
|
+
sessionId: string;
|
|
15
|
+
preview: string;
|
|
16
|
+
title?: string;
|
|
17
|
+
ts: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const MAX_TOASTS = 4;
|
|
21
|
+
const TOAST_TTL = 0; // 0 = sticky (no auto-dismiss), positive ms = auto-expire
|
|
22
|
+
|
|
23
|
+
export function useUnread(
|
|
24
|
+
socket: AgentSocket | null,
|
|
25
|
+
activeSessionId: string | undefined,
|
|
26
|
+
) {
|
|
27
|
+
const [unreads, setUnreads] = useState<Record<string, UnreadSession>>({});
|
|
28
|
+
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
29
|
+
const activeRef = useRef(activeSessionId);
|
|
30
|
+
activeRef.current = activeSessionId;
|
|
31
|
+
const focusedRef = useRef(!document.hidden);
|
|
32
|
+
const socketRef = useRef(socket);
|
|
33
|
+
socketRef.current = socket;
|
|
34
|
+
|
|
35
|
+
// Track tab focus
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const onVisChange = () => {
|
|
38
|
+
focusedRef.current = !document.hidden;
|
|
39
|
+
socketRef.current?.sendPresence(activeRef.current, focusedRef.current);
|
|
40
|
+
if (focusedRef.current && activeRef.current) {
|
|
41
|
+
markRead(activeRef.current);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
const onFocus = () => {
|
|
45
|
+
focusedRef.current = true;
|
|
46
|
+
socketRef.current?.sendPresence(activeRef.current, true);
|
|
47
|
+
if (activeRef.current) markRead(activeRef.current);
|
|
48
|
+
};
|
|
49
|
+
const onBlur = () => {
|
|
50
|
+
focusedRef.current = false;
|
|
51
|
+
socketRef.current?.sendPresence(activeRef.current, false);
|
|
52
|
+
};
|
|
53
|
+
document.addEventListener('visibilitychange', onVisChange);
|
|
54
|
+
window.addEventListener('focus', onFocus);
|
|
55
|
+
window.addEventListener('blur', onBlur);
|
|
56
|
+
return () => {
|
|
57
|
+
document.removeEventListener('visibilitychange', onVisChange);
|
|
58
|
+
window.removeEventListener('focus', onFocus);
|
|
59
|
+
window.removeEventListener('blur', onBlur);
|
|
60
|
+
};
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
// Send presence when active session changes
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
socket?.sendPresence(activeSessionId, focusedRef.current);
|
|
66
|
+
if (activeSessionId && focusedRef.current) {
|
|
67
|
+
markRead(activeSessionId);
|
|
68
|
+
}
|
|
69
|
+
}, [activeSessionId, socket]);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!TOAST_TTL || toasts.length === 0) return;
|
|
73
|
+
const timer = setInterval(() => {
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
setToasts(prev => prev.filter(t => now - t.ts < TOAST_TTL));
|
|
76
|
+
}, 5_000);
|
|
77
|
+
return () => clearInterval(timer);
|
|
78
|
+
}, [toasts.length]);
|
|
79
|
+
|
|
80
|
+
const handleUnreadEvent = useCallback((event: ServerEvent) => {
|
|
81
|
+
if (event.type === 'unread') {
|
|
82
|
+
const isViewingThis = activeRef.current === event.sessionId && focusedRef.current;
|
|
83
|
+
if (isViewingThis) {
|
|
84
|
+
socketRef.current?.markRead(event.sessionId);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
setUnreads(prev => ({
|
|
88
|
+
...prev,
|
|
89
|
+
[event.sessionId]: {
|
|
90
|
+
count: event.count,
|
|
91
|
+
preview: event.preview,
|
|
92
|
+
source: event.source,
|
|
93
|
+
title: event.title,
|
|
94
|
+
},
|
|
95
|
+
}));
|
|
96
|
+
setToasts(prev => {
|
|
97
|
+
const existing = prev.filter(t => t.sessionId !== event.sessionId);
|
|
98
|
+
const toast: ToastItem = {
|
|
99
|
+
id: `${event.sessionId}-${Date.now()}`,
|
|
100
|
+
sessionId: event.sessionId,
|
|
101
|
+
preview: event.preview,
|
|
102
|
+
title: event.title,
|
|
103
|
+
ts: Date.now(),
|
|
104
|
+
};
|
|
105
|
+
return [toast, ...existing].slice(0, MAX_TOASTS);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (event.type === 'unread_sync') {
|
|
110
|
+
const mapped: Record<string, UnreadSession> = {};
|
|
111
|
+
for (const [sid, entry] of Object.entries(event.sessions)) {
|
|
112
|
+
if (sid === activeRef.current && focusedRef.current) {
|
|
113
|
+
socketRef.current?.markRead(sid);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
mapped[sid] = {
|
|
117
|
+
count: entry.count,
|
|
118
|
+
preview: entry.preview,
|
|
119
|
+
source: entry.source as UnreadSession['source'],
|
|
120
|
+
title: entry.title,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
setUnreads(mapped);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (event.type === 'unread_cleared') {
|
|
127
|
+
setUnreads(prev => {
|
|
128
|
+
const next = { ...prev };
|
|
129
|
+
delete next[event.sessionId];
|
|
130
|
+
return next;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}, []);
|
|
134
|
+
|
|
135
|
+
const markRead = useCallback((sessionId: string) => {
|
|
136
|
+
setUnreads(prev => {
|
|
137
|
+
if (!prev[sessionId]) return prev;
|
|
138
|
+
const next = { ...prev };
|
|
139
|
+
delete next[sessionId];
|
|
140
|
+
return next;
|
|
141
|
+
});
|
|
142
|
+
setToasts(prev => prev.filter(t => t.sessionId !== sessionId));
|
|
143
|
+
socketRef.current?.markRead(sessionId);
|
|
144
|
+
}, []);
|
|
145
|
+
|
|
146
|
+
const dismissToast = useCallback((id: string) => {
|
|
147
|
+
setToasts(prev => prev.filter(t => t.id !== id));
|
|
148
|
+
}, []);
|
|
149
|
+
|
|
150
|
+
// Surface an ad-hoc in-app toast (e.g. a foreground native push) reusing the existing stack.
|
|
151
|
+
const addToast = useCallback((t: { sessionId: string; preview: string; title?: string }) => {
|
|
152
|
+
if (activeRef.current === t.sessionId && focusedRef.current) return; // already on that screen
|
|
153
|
+
setToasts(prev => {
|
|
154
|
+
const existing = prev.filter(x => x.sessionId !== t.sessionId);
|
|
155
|
+
const toast: ToastItem = { id: `${t.sessionId}-${Date.now()}`, sessionId: t.sessionId, preview: t.preview, title: t.title, ts: Date.now() };
|
|
156
|
+
return [toast, ...existing].slice(0, MAX_TOASTS);
|
|
157
|
+
});
|
|
158
|
+
}, []);
|
|
159
|
+
|
|
160
|
+
const totalUnread = Object.keys(unreads).length;
|
|
161
|
+
|
|
162
|
+
// Update document title and favicon
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
const base = 'Shraga';
|
|
165
|
+
document.title = totalUnread > 0 ? `(${totalUnread}) ${base}` : base;
|
|
166
|
+
updateFavicon(totalUnread);
|
|
167
|
+
}, [totalUnread]);
|
|
168
|
+
|
|
169
|
+
// Desktop shell (appwrap/macOS): mirror the unread count on the Dock badge, and bounce the Dock
|
|
170
|
+
// icon when a NEW unread arrives while the window is unfocused. No-op on web/mobile/no-kit.
|
|
171
|
+
const prevUnreadRef = useRef(0);
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
setDesktopBadge(totalUnread);
|
|
174
|
+
if (totalUnread > prevUnreadRef.current) requestDesktopAttention(false); // completed response → informational
|
|
175
|
+
prevUnreadRef.current = totalUnread;
|
|
176
|
+
}, [totalUnread]);
|
|
177
|
+
|
|
178
|
+
return { unreads, toasts, totalUnread, markRead, dismissToast, addToast, handleUnreadEvent };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let faviconCanvas: HTMLCanvasElement | null = null;
|
|
182
|
+
let originalFavicon: string | null = null;
|
|
183
|
+
|
|
184
|
+
function updateFavicon(count: number) {
|
|
185
|
+
if (typeof document === 'undefined') return;
|
|
186
|
+
|
|
187
|
+
if (!originalFavicon) {
|
|
188
|
+
const existing = document.querySelector('link[rel="icon"]') as HTMLLinkElement | null;
|
|
189
|
+
originalFavicon = existing?.href ?? '';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (count === 0) {
|
|
193
|
+
setFaviconHref(originalFavicon || '');
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!faviconCanvas) {
|
|
198
|
+
faviconCanvas = document.createElement('canvas');
|
|
199
|
+
faviconCanvas.width = 32;
|
|
200
|
+
faviconCanvas.height = 32;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const ctx = faviconCanvas.getContext('2d');
|
|
204
|
+
if (!ctx) return;
|
|
205
|
+
|
|
206
|
+
ctx.clearRect(0, 0, 32, 32);
|
|
207
|
+
|
|
208
|
+
// Draw base circle icon
|
|
209
|
+
ctx.fillStyle = '#6366f1';
|
|
210
|
+
ctx.beginPath();
|
|
211
|
+
ctx.arc(16, 16, 14, 0, Math.PI * 2);
|
|
212
|
+
ctx.fill();
|
|
213
|
+
|
|
214
|
+
// Draw badge
|
|
215
|
+
ctx.fillStyle = '#ef4444';
|
|
216
|
+
ctx.beginPath();
|
|
217
|
+
ctx.arc(24, 8, 8, 0, Math.PI * 2);
|
|
218
|
+
ctx.fill();
|
|
219
|
+
|
|
220
|
+
// Draw count
|
|
221
|
+
ctx.fillStyle = '#fff';
|
|
222
|
+
ctx.font = 'bold 10px sans-serif';
|
|
223
|
+
ctx.textAlign = 'center';
|
|
224
|
+
ctx.textBaseline = 'middle';
|
|
225
|
+
ctx.fillText(count > 9 ? '9+' : String(count), 24, 8);
|
|
226
|
+
|
|
227
|
+
setFaviconHref(faviconCanvas.toDataURL());
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function setFaviconHref(href: string) {
|
|
231
|
+
let link = document.querySelector('link[rel="icon"]') as HTMLLinkElement | null;
|
|
232
|
+
if (!link) {
|
|
233
|
+
link = document.createElement('link');
|
|
234
|
+
link.rel = 'icon';
|
|
235
|
+
document.head.appendChild(link);
|
|
236
|
+
}
|
|
237
|
+
if (link.href !== href) link.href = href;
|
|
238
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
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';
|
|
10
|
+
|
|
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
|
+
*/
|
|
29
|
+
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;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** True only when running in the desktop shell. */
|
|
46
|
+
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();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
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).
|
|
59
|
+
*/
|
|
60
|
+
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));
|
|
65
|
+
}
|
|
66
|
+
|
|
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
|
+
*/
|
|
71
|
+
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));
|
|
75
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { initializeApp, type FirebaseApp } from 'firebase/app';
|
|
2
|
+
import { getAuth, GoogleAuthProvider, signInWithPopup, signOut, onAuthStateChanged, type Auth, type User } from 'firebase/auth';
|
|
3
|
+
import { canUseNativeGoogleSignIn, signInWithGoogleNative } from './googleAuthNative';
|
|
4
|
+
|
|
5
|
+
// Firebase is OPTIONAL — only initialized when VITE_FIREBASE_CONFIG_PROD is present (an optional
|
|
6
|
+
// add-on). This build ships with local auth and no Firebase config; guard so importing this
|
|
7
|
+
// module never throws when unconfigured.
|
|
8
|
+
const firebaseConfig = JSON.parse(import.meta.env.VITE_FIREBASE_CONFIG_PROD ?? '{}');
|
|
9
|
+
export const hasFirebase = !!firebaseConfig.apiKey;
|
|
10
|
+
|
|
11
|
+
let app: FirebaseApp | null = null;
|
|
12
|
+
export const auth: Auth | null = hasFirebase ? getAuth((app = initializeApp(firebaseConfig))) : null;
|
|
13
|
+
|
|
14
|
+
export async function signInWithGoogle() {
|
|
15
|
+
if (!auth) throw new Error('Firebase not configured');
|
|
16
|
+
// In the appwrap native shell, Google blocks OAuth in the embedded WebView (disallowed_useragent),
|
|
17
|
+
// so run it in the system browser via kit.oauth → signInWithCredential. Falls back to the web popup.
|
|
18
|
+
if (await canUseNativeGoogleSignIn()) {
|
|
19
|
+
await signInWithGoogleNative(auth);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
await signInWithPopup(auth, new GoogleAuthProvider());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function signOutUser() {
|
|
26
|
+
if (auth) await signOut(auth);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function onAuth(cb: (user: User | null) => void) {
|
|
30
|
+
if (!auth) { cb(null); return () => {}; }
|
|
31
|
+
return onAuthStateChanged(auth, cb);
|
|
32
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Google sign-in for the appwrap native shell.
|
|
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
|
+
|
|
14
|
+
const CLIENT_ID = import.meta.env.VITE_GOOGLE_IOS_OAUTH_CLIENT_ID as string | undefined;
|
|
15
|
+
|
|
16
|
+
/** True only inside the native shell, with the oauth module compiled in and a client id configured. */
|
|
17
|
+
export async function canUseNativeGoogleSignIn(): Promise<boolean> {
|
|
18
|
+
if (!CLIENT_ID) return false;
|
|
19
|
+
try {
|
|
20
|
+
await kit.ready();
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
return kit.is.native && kit.oauth.capability === 'native';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function base64Url(bytes: Uint8Array): string {
|
|
28
|
+
let s = '';
|
|
29
|
+
for (const b of bytes) s += String.fromCharCode(b);
|
|
30
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function sha256(input: string): Promise<Uint8Array> {
|
|
34
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
|
35
|
+
return new Uint8Array(digest);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The reversed iOS OAuth client id IS the custom URL scheme Google redirects back to.
|
|
39
|
+
* '1234-abc.apps.googleusercontent.com' → 'com.googleusercontent.apps.1234-abc' */
|
|
40
|
+
function reversedClientId(clientId: string): string {
|
|
41
|
+
return `com.googleusercontent.apps.${clientId.replace(/\.apps\.googleusercontent\.com$/, '')}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Run the native Google sign-in flow against the given Firebase Auth. Throws on failure. */
|
|
45
|
+
export async function signInWithGoogleNative(auth: Auth): Promise<void> {
|
|
46
|
+
if (!CLIENT_ID) throw new Error('VITE_GOOGLE_IOS_OAUTH_CLIENT_ID not configured');
|
|
47
|
+
await kit.ready();
|
|
48
|
+
if (kit.oauth.capability !== 'native') {
|
|
49
|
+
throw new Error('kit.oauth unavailable — shell built without the "oauth" module');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const scheme = reversedClientId(CLIENT_ID);
|
|
53
|
+
const redirectUri = `${scheme}:/oauth2redirect`;
|
|
54
|
+
const verifier = base64Url(crypto.getRandomValues(new Uint8Array(32)));
|
|
55
|
+
const challenge = base64Url(await sha256(verifier));
|
|
56
|
+
|
|
57
|
+
const authUrl =
|
|
58
|
+
'https://accounts.google.com/o/oauth2/v2/auth?' +
|
|
59
|
+
new URLSearchParams({
|
|
60
|
+
client_id: CLIENT_ID,
|
|
61
|
+
redirect_uri: redirectUri,
|
|
62
|
+
response_type: 'code',
|
|
63
|
+
scope: 'openid email profile',
|
|
64
|
+
code_challenge: challenge,
|
|
65
|
+
code_challenge_method: 'S256',
|
|
66
|
+
}).toString();
|
|
67
|
+
|
|
68
|
+
console.log('[auth] native Google sign-in via system browser');
|
|
69
|
+
const { url } = await kit.oauth.authorize({ url: authUrl, callbackScheme: scheme });
|
|
70
|
+
|
|
71
|
+
const code = new URL(url).searchParams.get('code');
|
|
72
|
+
if (!code) throw new Error('OAuth callback missing authorization code');
|
|
73
|
+
|
|
74
|
+
// iOS OAuth clients are "installed apps" — PKCE, no client secret.
|
|
75
|
+
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
78
|
+
body: new URLSearchParams({
|
|
79
|
+
client_id: CLIENT_ID,
|
|
80
|
+
code,
|
|
81
|
+
code_verifier: verifier,
|
|
82
|
+
grant_type: 'authorization_code',
|
|
83
|
+
redirect_uri: redirectUri,
|
|
84
|
+
}).toString(),
|
|
85
|
+
});
|
|
86
|
+
if (!tokenRes.ok) {
|
|
87
|
+
const body = await tokenRes.text().catch(() => '');
|
|
88
|
+
throw new Error(`token exchange failed: HTTP ${tokenRes.status} — ${body}`);
|
|
89
|
+
}
|
|
90
|
+
const { id_token } = await tokenRes.json();
|
|
91
|
+
if (!id_token) throw new Error('token response missing id_token');
|
|
92
|
+
|
|
93
|
+
await signInWithCredential(auth, GoogleAuthProvider.credential(id_token));
|
|
94
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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';
|
|
7
|
+
|
|
8
|
+
export type { PushMessage, PushToken };
|
|
9
|
+
|
|
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;
|
|
14
|
+
|
|
15
|
+
/**
|
|
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).
|
|
18
|
+
*/
|
|
19
|
+
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;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Current page visibility (Page Visibility API). True when the tab/app is foregrounded. */
|
|
33
|
+
export function isVisible(): boolean {
|
|
34
|
+
return typeof document === 'undefined' ? true : !document.hidden;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Subscribe to foreground/background changes. Returns an unsubscribe fn. */
|
|
38
|
+
export function onVisibilityChange(cb: (visible: boolean) => void): () => void {
|
|
39
|
+
if (typeof document === 'undefined') return () => {};
|
|
40
|
+
const handler = () => cb(!document.hidden);
|
|
41
|
+
document.addEventListener('visibilitychange', handler);
|
|
42
|
+
return () => document.removeEventListener('visibilitychange', handler);
|
|
43
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type Trigger =
|
|
2
|
+
| { kind: 'once'; at: number }
|
|
3
|
+
| { kind: 'interval'; everyMs: number }
|
|
4
|
+
| { kind: 'cron'; expr: string; tz: string }
|
|
5
|
+
| { kind: 'event'; source: string; match?: Record<string, string> };
|
|
6
|
+
|
|
7
|
+
export type Task =
|
|
8
|
+
| { kind: 'prompt'; prompt: string; model?: string }
|
|
9
|
+
| { kind: 'bash'; command: string; model?: string }
|
|
10
|
+
| { kind: 'job'; command: string };
|
|
11
|
+
|
|
12
|
+
export type Scope = 'system' | 'user';
|
|
13
|
+
|
|
14
|
+
export interface ScheduleRunSummary {
|
|
15
|
+
at: number;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
status: 'running' | 'ok' | 'error' | 'aborted';
|
|
18
|
+
error?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Schedule {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
trigger: Trigger;
|
|
26
|
+
task: Task;
|
|
27
|
+
scope: Scope;
|
|
28
|
+
createdBy: { uid: string; email: string };
|
|
29
|
+
createdAt: number;
|
|
30
|
+
updatedAt: number;
|
|
31
|
+
nextRun?: number;
|
|
32
|
+
lastRun?: ScheduleRunSummary;
|
|
33
|
+
runCount: number;
|
|
34
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { randomUUID } from '@/lib/utils';
|
|
2
|
+
import type { ChatMessage, MessageBlock } from '@/hooks/useConversation';
|
|
3
|
+
|
|
4
|
+
/** Authenticated fetch with bearer token + timeout. Throws on !ok. */
|
|
5
|
+
export async function apiFetch(
|
|
6
|
+
path: string,
|
|
7
|
+
getToken: () => Promise<string | null>,
|
|
8
|
+
init?: RequestInit & { timeoutMs?: number },
|
|
9
|
+
) {
|
|
10
|
+
const token = await getToken();
|
|
11
|
+
if (!token) throw new Error('No auth token');
|
|
12
|
+
const timeout = init?.timeoutMs ?? 15_000;
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
if (init?.signal) init.signal.addEventListener('abort', () => controller.abort());
|
|
15
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetch(path, {
|
|
18
|
+
...init,
|
|
19
|
+
signal: controller.signal,
|
|
20
|
+
headers: { Authorization: `Bearer ${token}`, ...init?.headers },
|
|
21
|
+
});
|
|
22
|
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
23
|
+
return res;
|
|
24
|
+
} finally {
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Normalize a /api/sessions/:id/messages payload into ChatMessage[]. */
|
|
30
|
+
export function historyToMessages(data: { format: string; messages: any[] }): ChatMessage[] {
|
|
31
|
+
// Our own clean format
|
|
32
|
+
if (data.format === 'conv') {
|
|
33
|
+
return (data.messages as ChatMessage[]).filter((m) => m.blocks?.length > 0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Fallback: Claude's JSONL format
|
|
37
|
+
const result: ChatMessage[] = [];
|
|
38
|
+
for (const m of data.messages) {
|
|
39
|
+
const payload = m.message as any;
|
|
40
|
+
if (!payload?.content) continue;
|
|
41
|
+
const blocks: MessageBlock[] = [];
|
|
42
|
+
for (const block of payload.content) {
|
|
43
|
+
if (block.type === 'text' && block.text) blocks.push({ type: 'text', text: block.text });
|
|
44
|
+
else if (block.type === 'tool_use')
|
|
45
|
+
blocks.push({ type: 'tool_use', tool: block.name, toolUseId: block.id, input: block.input });
|
|
46
|
+
else if (block.type === 'tool_result') {
|
|
47
|
+
const output = Array.isArray(block.content)
|
|
48
|
+
? block.content.map((c: any) => c.text ?? '').join('')
|
|
49
|
+
: String(block.content ?? '');
|
|
50
|
+
blocks.push({ type: 'tool_result', toolUseId: String(block.tool_use_id), output });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (blocks.length > 0) {
|
|
54
|
+
result.push({ id: m.uuid || randomUUID(), role: m.type === 'user' ? 'user' : 'assistant', blocks });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|