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.
Files changed (180) hide show
  1. package/README.md +82 -27
  2. package/defaults/agents/summarizer.md +16 -0
  3. package/defaults/agents/trace-extractor.md +84 -0
  4. package/defaults/bin/claude +45 -0
  5. package/defaults/bin/claude-revive +17 -0
  6. package/defaults/extensions/README.md +70 -0
  7. package/defaults/extensions/selftest.ext.ts +43 -0
  8. package/defaults/extensions/stripe-webhook.ext.ts +58 -0
  9. package/defaults/gmail-triage-prompt.md +42 -0
  10. package/defaults/scripts/README +4 -0
  11. package/defaults/scripts/agent-once.ts +67 -0
  12. package/defaults/scripts/backfill-slack-usernames.ts +82 -0
  13. package/defaults/scripts/notifier-throttle.ts +44 -0
  14. package/defaults/scripts/summarize-conversations.ts +5 -0
  15. package/defaults/shraga.config.ts +29 -0
  16. package/defaults/skills/add-skill.md +14 -0
  17. package/defaults/skills/artifacts.md +116 -0
  18. package/defaults/skills/code-review.md +26 -0
  19. package/defaults/skills/communications.md +54 -0
  20. package/defaults/skills/context-audit.md +87 -0
  21. package/defaults/skills/debug.md +10 -0
  22. package/defaults/skills/garden.md +179 -0
  23. package/defaults/skills/github-contributor.md +35 -0
  24. package/defaults/skills/identity.md +30 -0
  25. package/defaults/skills/mcp-server.md +62 -0
  26. package/defaults/skills/mcps-sync.md +105 -0
  27. package/defaults/skills/plan.md +9 -0
  28. package/defaults/skills/platform.md +177 -0
  29. package/defaults/skills/reconcile.md +239 -0
  30. package/defaults/skills/scheduler.md +192 -0
  31. package/defaults/skills/self-aware.md +136 -0
  32. package/defaults/skills/shraga-know.md +333 -0
  33. package/defaults/skills/stripe.md +55 -0
  34. package/defaults/skills/write-tests.md +10 -0
  35. package/defaults/skills-defaults.json +1 -0
  36. package/defaults/system-prompt.md +46 -0
  37. package/defaults/workspace/context.md +28 -0
  38. package/defaults/workspace.md +50 -0
  39. package/defaults/zdotdir/.gitignore +8 -0
  40. package/defaults/zdotdir/.zlogin +3 -0
  41. package/defaults/zdotdir/.zprofile +1 -0
  42. package/defaults/zdotdir/.zshenv +4 -0
  43. package/defaults/zdotdir/.zshrc +3 -0
  44. package/dist/client/assets/index-BoHttkMt.js +1940 -0
  45. package/dist/client/assets/index-DdibEb2O.css +10 -0
  46. package/dist/client/index.html +22 -0
  47. package/package.json +59 -14
  48. package/src/cli.ts +71 -46
  49. package/src/client/App.tsx +510 -0
  50. package/src/client/components/ArtifactCard.tsx +26 -0
  51. package/src/client/components/ArtifactPanel.tsx +138 -0
  52. package/src/client/components/AuthedImage.tsx +85 -0
  53. package/src/client/components/AutocompleteTextarea.tsx +149 -0
  54. package/src/client/components/ChatView.tsx +866 -0
  55. package/src/client/components/CliAuthConsent.tsx +98 -0
  56. package/src/client/components/ConfigPanel.tsx +328 -0
  57. package/src/client/components/ConversationHeader.tsx +156 -0
  58. package/src/client/components/ConversationPane.tsx +277 -0
  59. package/src/client/components/LoginPage.tsx +81 -0
  60. package/src/client/components/MachineStats.tsx +77 -0
  61. package/src/client/components/McpManager.tsx +209 -0
  62. package/src/client/components/MessageInput.tsx +263 -0
  63. package/src/client/components/OAuthConsent.tsx +103 -0
  64. package/src/client/components/SchedulesManager.tsx +99 -0
  65. package/src/client/components/Sidebar.tsx +235 -0
  66. package/src/client/components/SkillsManager.tsx +280 -0
  67. package/src/client/components/SmartChart.tsx +167 -0
  68. package/src/client/components/Toast.tsx +54 -0
  69. package/src/client/components/WorkspaceTree.tsx +313 -0
  70. package/src/client/components/ZoomableImage.tsx +123 -0
  71. package/src/client/components/artifact-presets.ts +10 -0
  72. package/src/client/components/schedules/ScheduleEditor.tsx +264 -0
  73. package/src/client/components/schedules/ScheduleList.tsx +271 -0
  74. package/src/client/components/ui/accordion.tsx +50 -0
  75. package/src/client/components/ui/button.tsx +43 -0
  76. package/src/client/components/ui/dialog.tsx +82 -0
  77. package/src/client/components/ui/input.tsx +19 -0
  78. package/src/client/components/ui/scroll-area.tsx +39 -0
  79. package/src/client/components/ui/textarea.tsx +18 -0
  80. package/src/client/globals.css +51 -0
  81. package/src/client/hooks/useAgentSocket.ts +79 -0
  82. package/src/client/hooks/useArtifacts.ts +89 -0
  83. package/src/client/hooks/useAuth.ts +127 -0
  84. package/src/client/hooks/useConversation.ts +412 -0
  85. package/src/client/hooks/useDarkMode.ts +57 -0
  86. package/src/client/hooks/useIsMobile.ts +23 -0
  87. package/src/client/hooks/usePush.ts +127 -0
  88. package/src/client/hooks/useSchedules.ts +73 -0
  89. package/src/client/hooks/useUnread.ts +238 -0
  90. package/src/client/lib/desktopAttention.ts +75 -0
  91. package/src/client/lib/firebase.ts +32 -0
  92. package/src/client/lib/googleAuthNative.ts +94 -0
  93. package/src/client/lib/native.ts +43 -0
  94. package/src/client/lib/schedule-types.ts +34 -0
  95. package/src/client/lib/sessionApi.ts +58 -0
  96. package/src/client/lib/slots.tsx +79 -0
  97. package/src/client/lib/storage.ts +39 -0
  98. package/src/client/lib/utils.ts +26 -0
  99. package/src/client/lib/workspaceContext.tsx +54 -0
  100. package/src/client/lib/ws.ts +203 -0
  101. package/src/client/main.tsx +14 -0
  102. package/src/mcp-stdio-bridge.ts +70 -0
  103. package/src/scripts/summarize-conversations.ts +5 -0
  104. package/src/scripts/typecheck.ts +43 -0
  105. package/src/server/agents.ts +54 -0
  106. package/src/server/api-keys.ts +63 -0
  107. package/src/server/artifacts/artifacts.export.ts +85 -0
  108. package/src/server/artifacts/artifacts.handler.ts +93 -0
  109. package/src/server/artifacts/artifacts.routes.ts +43 -0
  110. package/src/server/artifacts/artifacts.service.ts +100 -0
  111. package/src/server/artifacts/artifacts.types.ts +31 -0
  112. package/src/server/auth.ts +262 -0
  113. package/src/server/claude.ts +394 -0
  114. package/src/server/commands.ts +21 -0
  115. package/src/server/contacts.ts +177 -0
  116. package/src/server/conversation-summarizer.ts +204 -0
  117. package/src/server/data-sync.ts +664 -0
  118. package/src/server/directives.ts +91 -0
  119. package/src/server/engine/claude-code.ts +514 -0
  120. package/src/server/engine/index.ts +41 -0
  121. package/src/server/engine/registry.ts +21 -0
  122. package/src/server/engine/shared.ts +47 -0
  123. package/src/server/engine/types.ts +48 -0
  124. package/src/server/env-resolve.ts +71 -0
  125. package/src/server/env-sanitize.ts +9 -0
  126. package/src/server/events/bus.ts +29 -0
  127. package/src/server/events/dispatcher.ts +48 -0
  128. package/src/server/events/routes.ts +19 -0
  129. package/src/server/events/types.ts +9 -0
  130. package/src/server/extensions.ts +101 -0
  131. package/src/server/features.ts +109 -0
  132. package/src/server/file-inject.ts +45 -0
  133. package/src/server/hooks.ts +142 -0
  134. package/src/server/idempotency.ts +25 -0
  135. package/src/server/index.ts +1715 -0
  136. package/src/server/integrity-audit.ts +132 -0
  137. package/src/server/mcp-catalog.ts +70 -0
  138. package/src/server/mcp-oauth.ts +198 -0
  139. package/src/server/mcp-progress.ts +45 -0
  140. package/src/server/mcp-server.ts +456 -0
  141. package/src/server/mcp-sidecar.ts +87 -0
  142. package/src/server/mcp.ts +291 -0
  143. package/src/server/model-aliases.ts +76 -0
  144. package/src/server/paths.ts +24 -0
  145. package/src/server/polls.ts +175 -0
  146. package/src/server/push/apns.ts +113 -0
  147. package/src/server/push/fcm.ts +108 -0
  148. package/src/server/push/push.ts +66 -0
  149. package/src/server/push/store.ts +84 -0
  150. package/src/server/push/triggers.ts +99 -0
  151. package/src/server/scheduler/builtins.ts +157 -0
  152. package/src/server/scheduler/engine.ts +432 -0
  153. package/src/server/scheduler/index.ts +4 -0
  154. package/src/server/scheduler/runner.ts +334 -0
  155. package/src/server/scheduler/storage.ts +98 -0
  156. package/src/server/scheduler/timing.ts +70 -0
  157. package/src/server/scheduler/types.ts +62 -0
  158. package/src/server/sdk-utils.ts +45 -0
  159. package/src/server/seed.ts +174 -0
  160. package/src/server/session-bus.ts +18 -0
  161. package/src/server/sessions.ts +559 -0
  162. package/src/server/shraga-config.ts +167 -0
  163. package/src/server/skills.ts +372 -0
  164. package/src/server/slack/api.ts +37 -0
  165. package/src/server/slack/bot.ts +391 -0
  166. package/src/server/slack/context-cache.ts +42 -0
  167. package/src/server/slack/feature.ts +59 -0
  168. package/src/server/slack/mention-rewrite.ts +59 -0
  169. package/src/server/slack/oauth.ts +102 -0
  170. package/src/server/slack/questions.ts +112 -0
  171. package/src/server/slack/sessions.ts +139 -0
  172. package/src/server/stats.ts +106 -0
  173. package/src/server/summarize.ts +11 -0
  174. package/src/server/turn-context.ts +61 -0
  175. package/src/server/unclaw-config.ts +19 -0
  176. package/src/server/unread.ts +79 -0
  177. package/src/server/user-context.ts +33 -0
  178. package/src/server/vendor-sync.ts +52 -0
  179. package/src/server/voice-provider.ts +74 -0
  180. package/src/server/workspace.ts +249 -0
@@ -0,0 +1,277 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import type { ServerEvent } from '@/lib/ws';
3
+ import { useConversation, type Attachment, type ChatMessage, type MessageBlock } from '@/hooks/useConversation';
4
+ import { useArtifacts } from '@/hooks/useArtifacts';
5
+ import { apiFetch, historyToMessages } from '@/lib/sessionApi';
6
+ import { useWorkspace } from '@/lib/workspaceContext';
7
+ import { useSlots, type ConversationControllerCtx } from '@/lib/slots';
8
+ import { randomUUID } from '@/lib/utils';
9
+ import { ChatView } from '@/components/ChatView';
10
+ import { MessageInput, type MessageInputHandle } from '@/components/MessageInput';
11
+ import { ArtifactPanel } from '@/components/ArtifactPanel';
12
+ import { ConversationHeader } from '@/components/ConversationHeader';
13
+
14
+ interface SessionDirectives {
15
+ model?: string;
16
+ turns?: number;
17
+ thinking?: string;
18
+ engine?: string;
19
+ }
20
+
21
+ /**
22
+ * A single live conversation. Owns its own message stream, artifacts, and session metadata — all
23
+ * scoped to `sessionId` — over the shared socket from {@link useWorkspace}. The core wires chat only;
24
+ * optional add-on extensions live behind {@link ClientSlots}, filled by an add-on build.
25
+ */
26
+ export function ConversationPane({ nodeId, sessionId }: { nodeId: string; sessionId?: string }) {
27
+ const ws = useWorkspace();
28
+ const slots = useSlots();
29
+ const inputRef = useRef<MessageInputHandle>(null);
30
+
31
+ // Add-on seams (all inert in the core): an opaque per-pane input handle forwarded to inputAdornments,
32
+ // a provider of opaque per-send options merged into every send, and add-on status items for statusChips.
33
+ const [inputCtx, setInputCtx] = useState<unknown>();
34
+ const [statusItems, setStatusItems] = useState<unknown[]>([]);
35
+ const sendOptionsRef = useRef<(() => Record<string, unknown> | undefined) | undefined>(undefined);
36
+
37
+ // `sessionId` is the tab's identity at mount; a fresh chat adopts its real id mid-stream (below).
38
+ const [currentSessionId, setCurrentSessionId] = useState<string | undefined>(sessionId);
39
+ const currentSessionIdRef = useRef(currentSessionId);
40
+ currentSessionIdRef.current = currentSessionId;
41
+ const initialSessionId = useRef(sessionId).current;
42
+
43
+ const [sessionDirectives, setSessionDirectives] = useState<SessionDirectives | undefined>();
44
+ const [sessionScheduleId, setSessionScheduleId] = useState<string | undefined>();
45
+ const [sessionLastModel, setSessionLastModel] = useState<string | undefined>();
46
+ const [multiParticipant, setMultiParticipant] = useState(false);
47
+
48
+ const artifacts = useArtifacts(currentSessionId, ws.getToken, ws.token);
49
+ const busyRef = useRef(false);
50
+
51
+ const handleNewSession = useCallback(
52
+ (id: string) => {
53
+ ws.bumpSidebar();
54
+ if (id === currentSessionIdRef.current) return; // turn `done` on the same session — no re-assign churn.
55
+ setCurrentSessionId(id);
56
+ ws.assignSession(nodeId, id);
57
+ },
58
+ [ws, nodeId],
59
+ );
60
+
61
+ // Per-pane raw events (filtered to this session): artifacts, model pill, tab title.
62
+ const handlePaneEvent = useCallback(
63
+ (event: ServerEvent) => {
64
+ if (event.type === 'artifact') {
65
+ if (event.sessionId === currentSessionIdRef.current) artifacts.handleArtifactEvent(event);
66
+ } else if (event.type === 'directives') {
67
+ // Optimistic-then-confirm pill: drop stale ground-truth so the pill shows the just-requested
68
+ // model. Untagged event — gate on this pane being mid-turn so idle panes don't flicker.
69
+ if (busyRef.current) setSessionLastModel(undefined);
70
+ } else if (event.type === 'model_resolved' && event.sessionId === currentSessionIdRef.current) {
71
+ setSessionLastModel(event.model);
72
+ }
73
+ },
74
+ [ws, nodeId, artifacts],
75
+ );
76
+
77
+ const conv = useConversation(ws.socket, {
78
+ getToken: ws.getToken,
79
+ initialSessionId: sessionId,
80
+ onNewSession: handleNewSession,
81
+ onDirectivesChanged: setSessionDirectives,
82
+ onParticipantsChanged: setMultiParticipant,
83
+ onEvent: handlePaneEvent,
84
+ });
85
+ busyRef.current = conv.busy;
86
+
87
+ // Load history once at mount (only if this tab opened onto an existing session). A fresh chat
88
+ // starts empty and accumulates from the stream — never reload it, or in-flight tokens are lost.
89
+ useEffect(() => {
90
+ if (!initialSessionId) return;
91
+ let aborted = false;
92
+ (async () => {
93
+ try {
94
+ const res = await apiFetch(`/api/sessions/${initialSessionId}/messages`, ws.getToken);
95
+ const data = await res.json();
96
+ if (aborted) return;
97
+ conv.setMessages(historyToMessages(data));
98
+ setMultiParticipant(Array.isArray(data.participants) && data.participants.length > 1);
99
+ if (data.busy) conv.setBusy(true);
100
+ } catch (err) {
101
+ if (!aborted) console.error('[pane] history load failed:', err);
102
+ }
103
+ })();
104
+ return () => {
105
+ aborted = true;
106
+ };
107
+ // eslint-disable-next-line react-hooks/exhaustive-deps
108
+ }, [initialSessionId]);
109
+
110
+ // Load session directives/meta whenever the bound session changes (safe — touches no messages).
111
+ useEffect(() => {
112
+ if (!currentSessionId) {
113
+ setSessionDirectives(undefined);
114
+ setSessionScheduleId(undefined);
115
+ setSessionLastModel(undefined);
116
+ return;
117
+ }
118
+ apiFetch(`/api/sessions/${currentSessionId}/meta`, ws.getToken)
119
+ .then((r) => r.json())
120
+ .then((meta: any) => {
121
+ setSessionDirectives(meta.directives);
122
+ setSessionScheduleId(meta.scheduleId);
123
+ setSessionLastModel(meta.lastModel);
124
+ if (meta.runStatus === 'running') conv.setBusy(true);
125
+ })
126
+ .catch(() => {});
127
+ // eslint-disable-next-line react-hooks/exhaustive-deps
128
+ }, [currentSessionId, ws.getToken]);
129
+
130
+ const handleSend = useCallback(
131
+ (text: string, truncateAt?: number, attachments?: Attachment[]) => {
132
+ // Merge any add-on-provided opaque send options (read at send time); undefined in the core.
133
+ conv.sendMessage(text, currentSessionIdRef.current, attachments, truncateAt, sendOptionsRef.current?.());
134
+ },
135
+ [conv],
136
+ );
137
+
138
+ // Headless per-conversation controller seam (add-on lifecycle + input/send/inject hooks). Rebuilt
139
+ // each render; an add-on binds via refs/effects. The core ships no controller, so nothing runs.
140
+ const controllerCtx: ConversationControllerCtx = {
141
+ sessionId: currentSessionId,
142
+ busy: conv.busy,
143
+ socket: ws.socket,
144
+ getToken: ws.getToken,
145
+ setInputCtx,
146
+ setSendOptions: (get) => { sendOptionsRef.current = get; },
147
+ setInputText: (t) => inputRef.current?.setText(t),
148
+ send: (text, options, meta) =>
149
+ conv.sendMessage(text, currentSessionIdRef.current, undefined, undefined, options, meta?.echo !== false),
150
+ appendAssistant: (blocks) =>
151
+ conv.setMessages((prev: ChatMessage[]) =>
152
+ prev.concat({ id: randomUUID(), role: 'assistant', blocks: blocks as unknown as MessageBlock[] })),
153
+ setStatusItems,
154
+ };
155
+
156
+ const handleUpload = useCallback(
157
+ async (file: File) => {
158
+ const t = await ws.getToken();
159
+ if (!t) return null;
160
+ try {
161
+ const res = await fetch('/api/upload', {
162
+ method: 'POST',
163
+ headers: {
164
+ Authorization: `Bearer ${t}`,
165
+ 'x-filename': encodeURIComponent(file.name),
166
+ 'Content-Type': file.type || 'application/octet-stream',
167
+ 'x-session-id': currentSessionIdRef.current || '',
168
+ },
169
+ body: file,
170
+ });
171
+ return (await res.json()) as Attachment;
172
+ } catch (err) {
173
+ console.error('[upload] Failed:', err);
174
+ return null;
175
+ }
176
+ },
177
+ [ws],
178
+ );
179
+
180
+ const handleFork = useCallback(
181
+ async (truncateAtIndex?: number) => {
182
+ const sid = currentSessionIdRef.current;
183
+ if (!sid) return;
184
+ const t = await ws.getToken();
185
+ if (!t) return;
186
+ try {
187
+ const res = await fetch(`/api/sessions/${sid}/fork`, {
188
+ method: 'POST',
189
+ headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
190
+ body: JSON.stringify(truncateAtIndex != null ? { truncateAtIndex } : {}),
191
+ });
192
+ if (!res.ok) {
193
+ console.error('[fork] server error:', (await res.json().catch(() => ({}))).error || res.status);
194
+ return;
195
+ }
196
+ const { sessionId: newId } = await res.json();
197
+ ws.bumpSidebar();
198
+ ws.openSession(newId);
199
+ } catch (err) {
200
+ console.error('[fork] failed:', err);
201
+ }
202
+ },
203
+ [ws],
204
+ );
205
+
206
+ const truncateAndSend = useCallback(
207
+ (msgId: string, text: string, attachments?: Attachment[]) => {
208
+ if (conv.busy) return;
209
+ const idx = conv.messages.findIndex((m) => m.id === msgId);
210
+ if (idx >= 0) conv.setMessages(conv.messages.slice(0, idx));
211
+ handleSend(text, idx >= 0 ? idx : undefined, attachments);
212
+ },
213
+ [conv, handleSend],
214
+ );
215
+
216
+ return (
217
+ <div className="flex flex-col h-full min-h-0">
218
+ {slots.conversationController?.(controllerCtx)}
219
+ <ConversationHeader
220
+ sessionId={currentSessionId}
221
+ agentConfig={ws.agentConfig}
222
+ sessionDirectives={sessionDirectives}
223
+ sessionLastModel={sessionLastModel}
224
+ sessionScheduleId={sessionScheduleId}
225
+ artifactCount={artifacts.artifacts.length}
226
+ getToken={ws.getToken}
227
+ onConfigSaved={ws.setAgentConfig}
228
+ onDirectivesSaved={setSessionDirectives}
229
+ onFork={() => handleFork()}
230
+ onToggleArtifacts={artifacts.togglePanel}
231
+ onScheduleClick={ws.openSchedules}
232
+ />
233
+ <div className="flex-1 flex min-h-0">
234
+ <div className="flex-1 flex flex-col min-w-0">
235
+ <ChatView
236
+ messages={conv.messages}
237
+ busy={conv.busy}
238
+ multiParticipant={multiParticipant}
239
+ connectionStatus={ws.connectionStatus ?? undefined}
240
+ onPermissionRespond={conv.respondPermission}
241
+ onQuestionRespond={conv.respondQuestion}
242
+ onReplay={truncateAndSend}
243
+ onEdit={truncateAndSend}
244
+ onFork={(messageIndex) => handleFork(messageIndex)}
245
+ statusItems={statusItems}
246
+ />
247
+ <MessageInput
248
+ ref={inputRef}
249
+ onSend={(text, attachments) => handleSend(text, undefined, attachments)}
250
+ onSteer={conv.steer}
251
+ onQueue={conv.addToQueue}
252
+ onRemoveQueued={conv.removeFromQueue}
253
+ queue={conv.queue}
254
+ onUpload={handleUpload}
255
+ onCancel={conv.cancel}
256
+ disabled={false}
257
+ busy={conv.busy}
258
+ skills={ws.skills}
259
+ workspaceFiles={ws.workspaceFiles}
260
+ sessionId={currentSessionId}
261
+ inputCtx={inputCtx}
262
+ />
263
+ </div>
264
+ {artifacts.panelOpen && artifacts.artifacts.length > 0 && currentSessionId && (
265
+ <ArtifactPanel
266
+ artifacts={artifacts.artifacts}
267
+ selectedId={artifacts.selectedId}
268
+ sessionId={currentSessionId}
269
+ getToken={ws.getToken}
270
+ onSelect={artifacts.selectArtifact}
271
+ onClose={artifacts.closePanel}
272
+ />
273
+ )}
274
+ </div>
275
+ </div>
276
+ );
277
+ }
@@ -0,0 +1,81 @@
1
+ import { Bot } from 'lucide-react';
2
+ import { Button } from './ui/button';
3
+ import { Input } from './ui/input';
4
+ import { signInWithGoogle } from '@/lib/firebase';
5
+ import { useState } from 'react';
6
+ import { useSlots } from '@/lib/slots';
7
+
8
+ interface LoginPageProps {
9
+ /** Active provider. 'local' → email/password form; 'firebase' → Google. */
10
+ mode?: 'local' | 'firebase' | null;
11
+ /** Local mode with no users yet → create-first-user form. */
12
+ needsSetup?: boolean;
13
+ /** Return an error message, or null on success. */
14
+ onLoginLocal?: (email: string, password: string) => Promise<string | null>;
15
+ onRegisterLocal?: (email: string, password: string) => Promise<string | null>;
16
+ }
17
+
18
+ export function LoginPage({ mode = 'firebase', needsSetup = false, onLoginLocal, onRegisterLocal }: LoginPageProps) {
19
+ const [loading, setLoading] = useState(false);
20
+ const [error, setError] = useState('');
21
+ const [email, setEmail] = useState('');
22
+ const [password, setPassword] = useState('');
23
+ const isLocal = mode === 'local';
24
+ const slots = useSlots();
25
+
26
+ async function handleGoogle() {
27
+ setLoading(true);
28
+ setError('');
29
+ try {
30
+ await signInWithGoogle();
31
+ } catch (e: any) {
32
+ setError(e.message || 'Sign-in failed');
33
+ } finally {
34
+ setLoading(false);
35
+ }
36
+ }
37
+
38
+ async function handleLocal(e: React.FormEvent) {
39
+ e.preventDefault();
40
+ setLoading(true);
41
+ setError('');
42
+ const fn = needsSetup ? onRegisterLocal : onLoginLocal;
43
+ const err = fn ? await fn(email.trim(), password) : 'Local auth unavailable';
44
+ if (err) setError(err);
45
+ setLoading(false);
46
+ }
47
+
48
+ return (
49
+ <div className="flex h-full items-center justify-center bg-background">
50
+ <div className="flex flex-col items-center gap-6 p-8 rounded-xl border bg-card shadow-sm w-full max-w-sm">
51
+ <div className="flex flex-col items-center gap-2">
52
+ <div className="flex items-center justify-center w-12 h-12 rounded-full bg-primary text-primary-foreground">
53
+ <Bot className="w-6 h-6" />
54
+ </div>
55
+ <h1 className="text-xl font-semibold">Shraga</h1>
56
+ <p className="text-sm text-muted-foreground text-center">
57
+ {isLocal ? (needsSetup ? 'Create your account to get started' : 'Sign in to your agent workspace') : 'Sign in to access the AI agent workspace'}
58
+ </p>
59
+ </div>
60
+
61
+ {error && <p className="text-sm text-destructive text-center">{error}</p>}
62
+
63
+ {isLocal ? (
64
+ <form onSubmit={handleLocal} className="flex flex-col gap-3 w-full">
65
+ <Input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} autoComplete="username" required />
66
+ <Input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete={needsSetup ? 'new-password' : 'current-password'} required />
67
+ <Button type="submit" disabled={loading || !email || !password} className="w-full">
68
+ {loading ? 'Please wait…' : needsSetup ? 'Create account' : 'Sign in'}
69
+ </Button>
70
+ </form>
71
+ ) : (
72
+ <Button onClick={handleGoogle} disabled={loading} className="w-full">
73
+ {loading ? 'Signing in…' : 'Continue with Google'}
74
+ </Button>
75
+ )}
76
+
77
+ {slots.loginExtras?.()}
78
+ </div>
79
+ </div>
80
+ );
81
+ }
@@ -0,0 +1,77 @@
1
+ import { useEffect, useState } from 'react';
2
+ import type { AgentSocket, ServerEvent } from '@/lib/ws';
3
+ import { cn } from '@/lib/utils';
4
+
5
+ type Sample = Extract<ServerEvent, { type: 'stats' }>['sample'];
6
+
7
+ const WINDOW = 120;
8
+
9
+ interface Props {
10
+ socket: AgentSocket | null;
11
+ getToken: () => Promise<string | null>;
12
+ }
13
+
14
+ // One shared sampler runs on the server; this just renders what it broadcasts.
15
+ export function MachineStats({ socket, getToken }: Props) {
16
+ const [samples, setSamples] = useState<Sample[]>([]);
17
+
18
+ // Seed from the cached server buffer once.
19
+ useEffect(() => {
20
+ getToken()
21
+ .then(t => (t ? fetch('/api/stats', { headers: { Authorization: `Bearer ${t}` } }) : null))
22
+ .then(r => r?.json())
23
+ .then(d => d?.samples && setSamples(d.samples))
24
+ .catch(err => console.warn('[MachineStats] seed failed', err));
25
+ }, [getToken]);
26
+
27
+ // Append live points over WS.
28
+ useEffect(() => {
29
+ if (!socket) return;
30
+ const off = socket.on(ev => {
31
+ if (ev.type === 'stats') {
32
+ setSamples(prev => [...prev, ev.sample].slice(-WINDOW));
33
+ }
34
+ });
35
+ return () => { off(); };
36
+ }, [socket]);
37
+
38
+ if (!samples.length) return null;
39
+ const latest = samples[samples.length - 1];
40
+
41
+ return (
42
+ <div className="flex items-center justify-center gap-2 text-[10px] text-muted-foreground/60">
43
+ <Metric label="cpu" value={latest.cpu} series={samples.map(s => s.cpu)} />
44
+ <Metric label="mem" value={latest.mem} series={samples.map(s => s.mem)} />
45
+ </div>
46
+ );
47
+ }
48
+
49
+ function level(v: number) {
50
+ return v >= 90 ? 'text-red-500' : v >= 75 ? 'text-amber-500' : 'text-emerald-500';
51
+ }
52
+
53
+ function Metric({ label, value, series }: { label: string; value: number; series: number[] }) {
54
+ return (
55
+ <span className="flex items-center gap-1" title={`${label} ${value}% — last ${series.length} samples`}>
56
+ <span className="uppercase tracking-wide">{label}</span>
57
+ <Sparkline series={series} className={level(value)} />
58
+ <span className={cn('tabular-nums', level(value))}>{value}%</span>
59
+ </span>
60
+ );
61
+ }
62
+
63
+ // Inline SVG sparkline (0-100 domain) — no chart lib, fixed viewBox so it scales crisply.
64
+ function Sparkline({ series, className }: { series: number[]; className?: string }) {
65
+ const W = 40, H = 12;
66
+ const n = series.length;
67
+ const pts = series.map((v, i) => {
68
+ const x = n > 1 ? (i / (n - 1)) * W : 0;
69
+ const y = H - (Math.max(0, Math.min(100, v)) / 100) * H;
70
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
71
+ }).join(' ');
72
+ return (
73
+ <svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className={cn('overflow-visible', className)} preserveAspectRatio="none">
74
+ <polyline points={pts} fill="none" stroke="currentColor" strokeWidth={1} strokeLinejoin="round" strokeLinecap="round" />
75
+ </svg>
76
+ );
77
+ }
@@ -0,0 +1,209 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Plus, Trash2, Settings, Lock } from 'lucide-react';
3
+ import { Button } from './ui/button';
4
+ import { Input } from './ui/input';
5
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogBody, DialogFooter } from './ui/dialog';
6
+
7
+ export interface McpServerConfig {
8
+ command: string;
9
+ args?: string[];
10
+ env?: Record<string, string>;
11
+ readonly?: boolean;
12
+ }
13
+ export type McpConfig = Record<string, McpServerConfig>;
14
+
15
+ interface Props {
16
+ getToken: () => Promise<string | null>;
17
+ trigger?: React.ReactNode;
18
+ }
19
+
20
+ const emptyServer = (): McpServerConfig => ({ command: '', args: [], env: {} });
21
+
22
+ export function McpManager({ getToken, trigger }: Props) {
23
+ const [config, setConfig] = useState<McpConfig>({});
24
+ const [open, setOpen] = useState(false);
25
+ const [saving, setSaving] = useState(false);
26
+ const [editingEnv, setEditingEnv] = useState<Set<string>>(new Set());
27
+ const toggleEditing = (id: string) =>
28
+ setEditingEnv((s) => { const next = new Set(s); next.has(id) ? next.delete(id) : next.add(id); return next; });
29
+
30
+ useEffect(() => {
31
+ if (!open) return;
32
+ getToken().then((token) => {
33
+ if (!token) return;
34
+ fetch('/api/mcps', { headers: { Authorization: `Bearer ${token}` } })
35
+ .then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
36
+ .then(setConfig)
37
+ .catch((e) => console.error('[McpManager] fetch failed', e));
38
+ });
39
+ }, [open, getToken]);
40
+
41
+ const save = async () => {
42
+ setSaving(true);
43
+ const token = await getToken();
44
+ try {
45
+ await fetch('/api/mcps', {
46
+ method: 'PUT',
47
+ headers: { Authorization: `Bearer ${token ?? ''}`, 'Content-Type': 'application/json' },
48
+ body: JSON.stringify(config),
49
+ });
50
+ setOpen(false);
51
+ } finally {
52
+ setSaving(false);
53
+ }
54
+ };
55
+
56
+ const addServer = () => {
57
+ const name = `server-${Date.now()}`;
58
+ setConfig((c) => ({ ...c, [name]: emptyServer() }));
59
+ };
60
+
61
+ const removeServer = (name: string) => {
62
+ setConfig((c) => {
63
+ const next = { ...c };
64
+ delete next[name];
65
+ return next;
66
+ });
67
+ };
68
+
69
+ const updateServer = (name: string, key: keyof McpServerConfig, value: string) => {
70
+ setConfig((c) => ({ ...c, [name]: { ...c[name], [key]: key === 'args' ? value.split(' ') : value } }));
71
+ };
72
+
73
+ const updateEnvKey = (name: string, oldKey: string, newKey: string) => {
74
+ setConfig((c) => {
75
+ const env = { ...c[name].env };
76
+ const val = env[oldKey] ?? '';
77
+ delete env[oldKey];
78
+ if (newKey.trim()) env[newKey.trim()] = val;
79
+ return { ...c, [name]: { ...c[name], env } };
80
+ });
81
+ };
82
+
83
+ const updateEnvVal = (name: string, key: string, val: string) => {
84
+ setConfig((c) => ({
85
+ ...c,
86
+ [name]: { ...c[name], env: { ...c[name].env, [key]: val } },
87
+ }));
88
+ };
89
+
90
+ const removeEnvKey = (name: string, key: string) => {
91
+ setConfig((c) => {
92
+ const env = { ...c[name].env };
93
+ delete env[key];
94
+ return { ...c, [name]: { ...c[name], env } };
95
+ });
96
+ };
97
+
98
+ const addEnvKey = (name: string) => {
99
+ const key = `KEY_${Date.now()}`;
100
+ setConfig((c) => ({
101
+ ...c,
102
+ [name]: { ...c[name], env: { ...c[name].env, [key]: '' } },
103
+ }));
104
+ };
105
+
106
+ return (
107
+ <Dialog open={open} onOpenChange={setOpen}>
108
+ <DialogTrigger asChild>
109
+ {trigger || (
110
+ <Button variant="ghost" size="icon" title="MCP Servers">
111
+ <Settings className="w-4 h-4" />
112
+ </Button>
113
+ )}
114
+ </DialogTrigger>
115
+
116
+ <DialogContent className="max-w-[95vw] sm:max-w-2xl">
117
+ <DialogHeader>
118
+ <DialogTitle>MCP Servers</DialogTitle>
119
+ </DialogHeader>
120
+
121
+ <DialogBody className="space-y-4">
122
+ {Object.entries(config).map(([name, server]) => {
123
+ const isReadonly = !!server.readonly;
124
+ return (
125
+ <div key={name} className={`border rounded-lg p-4 space-y-3 ${isReadonly ? 'opacity-70' : ''}`}>
126
+ <div className="flex items-center justify-between">
127
+ <span className="font-medium text-sm flex items-center gap-1.5">
128
+ {isReadonly && <Lock className="w-3 h-3 text-muted-foreground" />}
129
+ {name}
130
+ </span>
131
+ {!isReadonly && (
132
+ <Button variant="ghost" size="icon" onClick={() => removeServer(name)}>
133
+ <Trash2 className="w-4 h-4 text-destructive" />
134
+ </Button>
135
+ )}
136
+ </div>
137
+ {isReadonly ? (
138
+ <p className="text-xs text-muted-foreground">Configured in shraga.config.ts</p>
139
+ ) : (
140
+ <div className="grid gap-2">
141
+ <Input
142
+ placeholder="Command (e.g. npx)"
143
+ value={server.command}
144
+ onChange={(e) => updateServer(name, 'command', e.target.value)}
145
+ />
146
+ <Input
147
+ placeholder="Args (space-separated)"
148
+ value={(server.args || []).join(' ')}
149
+ onChange={(e) => updateServer(name, 'args', e.target.value)}
150
+ />
151
+ {Object.entries(server.env || {}).length > 0 && (
152
+ <div className="space-y-2">
153
+ <span className="text-xs text-muted-foreground">Environment variables</span>
154
+ {Object.entries(server.env || {}).map(([k, v]) => (
155
+ <div key={k} className="flex gap-2 items-center">
156
+ <Input
157
+ className="w-1/3 font-mono text-xs"
158
+ value={k}
159
+ onChange={(e) => updateEnvKey(name, k, e.target.value)}
160
+ placeholder="KEY"
161
+ />
162
+ {editingEnv.has(`${name}:${k}`) ? (
163
+ <Input
164
+ className="flex-1 font-mono text-xs"
165
+ placeholder="paste new value"
166
+ autoFocus
167
+ onChange={(e) => updateEnvVal(name, k, e.target.value)}
168
+ onBlur={() => toggleEditing(`${name}:${k}`)}
169
+ />
170
+ ) : (
171
+ <span
172
+ className="flex-1 font-mono text-xs text-muted-foreground truncate cursor-pointer px-3 py-2 border rounded-md bg-background hover:border-ring"
173
+ onClick={() => toggleEditing(`${name}:${k}`)}
174
+ title="Click to change"
175
+ >{v || '(empty — click to set)'}</span>
176
+ )}
177
+ <Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => removeEnvKey(name, k)}>
178
+ <Trash2 className="w-3 h-3 text-muted-foreground" />
179
+ </Button>
180
+ </div>
181
+ ))}
182
+ </div>
183
+ )}
184
+ <Button variant="ghost" size="sm" className="text-xs text-muted-foreground" onClick={() => addEnvKey(name)}>
185
+ + Add env var
186
+ </Button>
187
+ </div>
188
+ )}
189
+ </div>
190
+ );
191
+ })}
192
+
193
+ {Object.keys(config).length === 0 && (
194
+ <p className="text-sm text-muted-foreground text-center py-4">No MCP servers configured</p>
195
+ )}
196
+ </DialogBody>
197
+
198
+ <DialogFooter className="flex gap-2">
199
+ <Button variant="outline" onClick={addServer} className="gap-2">
200
+ <Plus className="w-4 h-4" /> Add Server
201
+ </Button>
202
+ <Button onClick={save} disabled={saving}>
203
+ {saving ? 'Saving…' : 'Save'}
204
+ </Button>
205
+ </DialogFooter>
206
+ </DialogContent>
207
+ </Dialog>
208
+ );
209
+ }