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,291 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { dataPath } from './paths.ts';
4
+ import { dataSync } from './data-sync.ts';
5
+ import { getGlobalMcpsFromConfig } from './shraga-config.ts';
6
+
7
+ const MCP_DIR = dataPath('mcps');
8
+
9
+ export interface McpStdioServerConfig {
10
+ /** When false, server stays in config for UI / sync but is omitted from the Claude SDK. Default: true. */
11
+ enabled?: boolean;
12
+ /** Claude Code CLI expects stdio transports to declare type (see --mcp-config validation). */
13
+ type?: 'stdio';
14
+ command: string;
15
+ args?: string[];
16
+ env?: Record<string, string>;
17
+ }
18
+
19
+ export interface McpHttpServerConfig {
20
+ enabled?: boolean;
21
+ type: 'http';
22
+ url: string;
23
+ headers?: Record<string, string>;
24
+ }
25
+
26
+ export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig;
27
+
28
+ export type McpConfig = Record<string, McpServerConfig>;
29
+
30
+ function loadJson(file: string): McpConfig {
31
+ if (!existsSync(file)) return {};
32
+ try {
33
+ return JSON.parse(readFileSync(file, 'utf-8'));
34
+ } catch {
35
+ return {};
36
+ }
37
+ }
38
+
39
+ function mcpPath(uid: string) {
40
+ return path.join(MCP_DIR, `${uid}.json`);
41
+ }
42
+
43
+ export function getGlobalMcpConfig(): McpConfig {
44
+ return getGlobalMcpsFromConfig();
45
+ }
46
+
47
+ export function getUserMcpConfig(uid: string): McpConfig {
48
+ return loadJson(mcpPath(uid));
49
+ }
50
+
51
+ function isHttpConfig(s: McpServerConfig): s is McpHttpServerConfig {
52
+ return s.type === 'http';
53
+ }
54
+
55
+ /** Resolve env values: empty → process.env[same key], "$VAR" → process.env[VAR] */
56
+ function resolveEnv(config: McpConfig): McpConfig {
57
+ const resolved: McpConfig = {};
58
+ for (const [name, server] of Object.entries(config)) {
59
+ if (isHttpConfig(server)) { resolved[name] = server; continue; }
60
+ if (!server.env) { resolved[name] = server; continue; }
61
+ const env: Record<string, string> = {};
62
+ for (const [k, v] of Object.entries(server.env)) {
63
+ // env values can be null/undefined/non-string if a config was edited by hand
64
+ // or synced from another instance — coerce defensively so resolution never throws.
65
+ if (v == null || v === '') {
66
+ env[k] = process.env[k] || '';
67
+ } else if (typeof v !== 'string') {
68
+ env[k] = String(v);
69
+ } else if (v.startsWith('$')) {
70
+ env[k] = process.env[v.slice(1)] || '';
71
+ } else {
72
+ env[k] = v;
73
+ }
74
+ }
75
+ // Env-gate: a server that DECLARES env keys but resolves them all empty isn't configured for
76
+ // this deployment (e.g. an MCP that needs STRIPE_SECRET_KEY when none is set) — skip it instead
77
+ // of mounting → failing every turn. Servers with no declared env, or any value present, pass.
78
+ if (Object.keys(env).length > 0 && Object.values(env).every((v) => !v)) {
79
+ console.log(`[mcp] ${name}: skipped — required env (${Object.keys(env).join(', ')}) not set in this deployment`);
80
+ continue;
81
+ }
82
+ resolved[name] = { ...server, env };
83
+ }
84
+ return resolved;
85
+ }
86
+
87
+ const MASK_CHAR = '••••';
88
+
89
+ function maskValue(v: string): string {
90
+ if (!v || v.length <= 8) return MASK_CHAR;
91
+ return `${v.slice(0, 4)}${MASK_CHAR}${v.slice(-4)}`;
92
+ }
93
+
94
+ function isMasked(v: string): boolean {
95
+ return v.includes(MASK_CHAR);
96
+ }
97
+
98
+ /** Mask env values for client display */
99
+ export function maskEnvValues(config: McpConfig): McpConfig {
100
+ const masked: McpConfig = {};
101
+ for (const [name, server] of Object.entries(config)) {
102
+ if (isHttpConfig(server)) { masked[name] = server; continue; }
103
+ if (!server.env) { masked[name] = server; continue; }
104
+ const env: Record<string, string> = {};
105
+ for (const [k, v] of Object.entries(server.env)) {
106
+ env[k] = v ? maskValue(v) : '';
107
+ }
108
+ masked[name] = { ...server, env };
109
+ }
110
+ return masked;
111
+ }
112
+
113
+ /** Merge incoming config, preserving original values for masked fields */
114
+ export function mergeWithOriginal(incoming: McpConfig, original: McpConfig): McpConfig {
115
+ const merged: McpConfig = {};
116
+ for (const [name, server] of Object.entries(incoming)) {
117
+ if (isHttpConfig(server)) { merged[name] = server; continue; }
118
+ if (!server.env) { merged[name] = server; continue; }
119
+ const orig = original[name];
120
+ const origEnv = (orig && !isHttpConfig(orig) ? orig.env : undefined) || {};
121
+ const env: Record<string, string> = {};
122
+ for (const [k, v] of Object.entries(server.env)) {
123
+ if (!isMasked(v)) {
124
+ env[k] = v;
125
+ continue;
126
+ }
127
+ const saved = origEnv[k];
128
+ env[k] =
129
+ saved != null && saved !== ''
130
+ ? saved
131
+ : (process.env[k] || '');
132
+ }
133
+ merged[name] = { ...server, env };
134
+ }
135
+ return merged;
136
+ }
137
+
138
+ /** Global MCPs merged with per-user overrides (raw, no env resolution) */
139
+ export function getRawMcpConfig(uid: string): McpConfig {
140
+ return { ...getGlobalMcpConfig(), ...getUserMcpConfig(uid) };
141
+ }
142
+
143
+ /** Ensure stdio MCP entries match CLI schema (avoids silent drops / validation issues). */
144
+ function withStdioType(config: McpConfig): McpConfig {
145
+ const out: McpConfig = {};
146
+ for (const [name, server] of Object.entries(config)) {
147
+ if (isHttpConfig(server)) { out[name] = server; continue; }
148
+ if (server.command && !server.type) out[name] = { type: 'stdio', ...server };
149
+ else out[name] = server;
150
+ }
151
+ return out;
152
+ }
153
+
154
+ const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..');
155
+
156
+ /** Canonical path baked into MCP env when prod has the file deployed (cwd = app dir). */
157
+ const GOOGLE_SA_DEPLOY_REL = './secrets/google-service-account.json';
158
+
159
+ /**
160
+ * Resolve GOOGLE_SERVICE_ACCOUNT for any MCP that declares it in env.
161
+ * - Inline JSON when env starts with `{`.
162
+ * - Fall back to `secrets/google-service-account.json` when the path is bogus.
163
+ */
164
+ function finalizeGoogleServiceAccountCredentials(config: McpConfig): McpConfig {
165
+ const jsonFromEnv = process.env.GOOGLE_SERVICE_ACCOUNT_JSON?.trim();
166
+ const defaultAbs = path.join(PROJECT_ROOT, 'secrets/google-service-account.json');
167
+ const defaultExists = existsSync(defaultAbs);
168
+
169
+ let result = { ...config };
170
+ for (const [name, entry] of Object.entries(result)) {
171
+ if (isHttpConfig(entry)) continue;
172
+ if (!entry.env || !('GOOGLE_SERVICE_ACCOUNT' in entry.env)) continue;
173
+
174
+ const accountFromResolve = entry.env.GOOGLE_SERVICE_ACCOUNT?.trim() ?? '';
175
+
176
+ if (jsonFromEnv?.startsWith('{')) {
177
+ result = { ...result, [name]: { ...entry, env: { ...entry.env, GOOGLE_SERVICE_ACCOUNT: jsonFromEnv } } };
178
+ continue;
179
+ }
180
+
181
+ if (accountFromResolve.startsWith('{')) continue;
182
+
183
+ const raw = accountFromResolve;
184
+ const pathOk =
185
+ raw &&
186
+ !raw.includes('${') &&
187
+ existsSync(path.isAbsolute(raw) ? raw : path.resolve(PROJECT_ROOT, raw.replace(/^\.\//, '')));
188
+
189
+ if (pathOk) continue;
190
+ if (!defaultExists) continue;
191
+
192
+ result = { ...result, [name]: { ...entry, env: { ...entry.env, GOOGLE_SERVICE_ACCOUNT: GOOGLE_SA_DEPLOY_REL } } };
193
+ }
194
+
195
+ return result;
196
+ }
197
+
198
+ /**
199
+ * For MCP entries that declare FIREBASE_DATABASE_URL + FIREBASE_SERVICE_ACCOUNT_JSON in env:
200
+ * resolve from FIREBASE_CONFIG_* JSON blobs when the dedicated env vars are empty.
201
+ */
202
+ function finalizeFirebaseCredentials(config: McpConfig): McpConfig {
203
+ let result = { ...config };
204
+ for (const [name, entry] of Object.entries(result)) {
205
+ if (isHttpConfig(entry)) continue;
206
+ if (!entry.env || !('FIREBASE_DATABASE_URL' in entry.env)) continue;
207
+
208
+ const patches: Record<string, string> = {};
209
+ const isEmpty = (v?: string) => !v || v.startsWith('${');
210
+
211
+ if (isEmpty(entry.env.FIREBASE_DATABASE_URL)) {
212
+ const suffix = name.includes('lab') ? 'LAB' : name.includes('prod') ? 'PROD' : '';
213
+ const configKeys = suffix
214
+ ? [`FIREBASE_CONFIG_${suffix}`, `VITE_FIREBASE_CONFIG_${suffix}`, 'FIREBASE_CONFIG']
215
+ : ['FIREBASE_CONFIG'];
216
+ const configJson = configKeys.map(k => process.env[k]).find(v => v?.trim());
217
+ if (configJson) try {
218
+ const parsed = JSON.parse(configJson);
219
+ if (parsed.databaseURL) patches.FIREBASE_DATABASE_URL = parsed.databaseURL;
220
+ } catch { /* not valid JSON */ }
221
+ }
222
+
223
+ if (isEmpty(entry.env.FIREBASE_SERVICE_ACCOUNT_JSON)) {
224
+ const suffix = name.includes('lab') ? 'LAB' : name.includes('prod') ? 'PROD' : '';
225
+ const saKeys = suffix
226
+ ? [`FIREBASE_SERVICE_ACCOUNT_JSON_${suffix}`, 'FIREBASE_SERVICE_ACCOUNT_JSON']
227
+ : ['FIREBASE_SERVICE_ACCOUNT_JSON'];
228
+ const sa = saKeys.map(k => process.env[k]).find(v => v?.trim());
229
+ if (sa) patches.FIREBASE_SERVICE_ACCOUNT_JSON = sa;
230
+ }
231
+
232
+ if (Object.keys(patches).length) {
233
+ result = { ...result, [name]: { ...entry, env: { ...entry.env, ...patches } } };
234
+ }
235
+ }
236
+ return result;
237
+ }
238
+
239
+ /** Full merged MCP config (includes `enabled: false` entries) for API/UI. */
240
+ export function getResolvedMcpConfig(uid: string): McpConfig {
241
+ return finalizeFirebaseCredentials(
242
+ finalizeGoogleServiceAccountCredentials(
243
+ resolveEnv(
244
+ withStdioType(
245
+ getRawMcpConfig(uid),
246
+ ),
247
+ ),
248
+ ),
249
+ );
250
+ }
251
+
252
+ /** Strip disabled servers and drop `enabled` before passing to Claude Code (unknown keys can break validation). */
253
+ function activeMcpConfigForSdk(config: McpConfig): McpConfig {
254
+ const out: McpConfig = {};
255
+ for (const [name, server] of Object.entries(config)) {
256
+ if (server.enabled === false) continue;
257
+ const sdkServer = { ...server };
258
+ delete (sdkServer as { enabled?: boolean }).enabled;
259
+ out[name] = sdkServer;
260
+ }
261
+ return out;
262
+ }
263
+
264
+ /** Active MCPs only — used by agent, schedulers, webhooks, CLI sync. */
265
+ export function getMcpConfig(uid: string): McpConfig {
266
+ return activeMcpConfigForSdk(getResolvedMcpConfig(uid));
267
+ }
268
+
269
+ /** Resolved, SDK-ready GLOBAL mcp config (no per-user overlay) — for boot/background catalog warm-up.
270
+ * The slow/shared servers are global, so warming this
271
+ * populates their catalog entries off the turn path. Per-user-only servers warm on first use. */
272
+ export function getGlobalMcpConfigForSdk(): McpConfig {
273
+ return activeMcpConfigForSdk(
274
+ finalizeFirebaseCredentials(
275
+ finalizeGoogleServiceAccountCredentials(
276
+ resolveEnv(
277
+ withStdioType(
278
+ getGlobalMcpConfig(),
279
+ ),
280
+ ),
281
+ ),
282
+ ),
283
+ );
284
+ }
285
+
286
+ export function saveMcpConfig(uid: string, config: McpConfig): void {
287
+ mkdirSync(MCP_DIR, { recursive: true });
288
+ writeFileSync(mcpPath(uid), JSON.stringify(config, null, 2));
289
+ dataSync.trackWrite(`mcps/${uid}.json`);
290
+ }
291
+
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Model aliasing + mid-conversation switch resolution — pure, dependency-free.
3
+ *
4
+ * The shared mechanism every model consumer in Shraga uses to resolve `[opus]`-style aliases,
5
+ * gate by an allow-list, and announce a switch identically. Policy (which models a caller may
6
+ * use) and session state (the prior model) stay with the consumer; this module is the mechanism.
7
+ */
8
+
9
+ /** Canonical short aliases → concrete Anthropic model ids. */
10
+ export const MODEL_ALIASES: Record<string, string> = {
11
+ fable: 'claude-fable-5',
12
+ 'fable-5': 'claude-fable-5',
13
+ opus: 'claude-opus-4-8',
14
+ 'opus-4-8': 'claude-opus-4-8',
15
+ 'opus-4-7': 'claude-opus-4-7',
16
+ 'opus-4-6': 'claude-opus-4-6',
17
+ sonnet: 'claude-sonnet-4-6',
18
+ haiku: 'claude-haiku-4-5-20251001',
19
+ };
20
+
21
+ /** Resolve an alias to a concrete id. A provider prefix ("anthropic/") is preserved; unknown ids pass through. */
22
+ export function resolveModelAlias(input: string): string {
23
+ const slash = input.indexOf('/');
24
+ const prefix = slash === -1 ? '' : input.slice(0, slash + 1);
25
+ const rest = slash === -1 ? input : input.slice(slash + 1);
26
+ return prefix + (MODEL_ALIASES[rest.toLowerCase()] ?? rest);
27
+ }
28
+
29
+ /** Short, human-friendly name for a resolved model id (e.g. "anthropic/claude-opus-4-8" → "opus"). */
30
+ export function modelShortLabel(model?: string): string {
31
+ if (!model) return 'default';
32
+ const id = model.split('/').pop()!;
33
+ return id.replace(/^claude-/, '').replace(/-\d.*$/, '');
34
+ }
35
+
36
+ export interface ResolveModelSwitchOpts {
37
+ /** Model requested for this turn (e.g. from an inline directive). Undefined = keep `current`. */
38
+ requested?: string;
39
+ /** The turn's default/current model when nothing is requested. */
40
+ current: string;
41
+ /** The session's prior resolved model — drives change detection + the notice. */
42
+ prior?: string;
43
+ /** If set, `requested` must match one of these (by short label) or it's denied and `current` is kept. */
44
+ allowed?: string[];
45
+ /** Announcement formatter. Default: `_[switching from x to y]_\n\n`. */
46
+ format?: (from: string, to: string) => string;
47
+ }
48
+
49
+ export interface ModelSwitch {
50
+ /** The model to run this turn. */
51
+ model: string;
52
+ /** True when `requested` was rejected by `allowed` and `model` fell back to `current`. */
53
+ denied: boolean;
54
+ /** Inline notice to surface when the model actually changed vs `prior`, else undefined. */
55
+ notice?: string;
56
+ }
57
+
58
+ /**
59
+ * Resolve which model a turn runs, given an optional request, an allow-list, and the prior model.
60
+ * Comparisons are by short label so provider-prefixed and dated ids unify
61
+ * ("anthropic/claude-opus-4-8" ≡ "claude-opus-4-8" ≡ "opus").
62
+ */
63
+ export function resolveModelSwitch(opts: ResolveModelSwitchOpts): ModelSwitch {
64
+ const { requested, current, prior, allowed } = opts;
65
+ if (!requested) return { model: current, denied: false };
66
+ if (allowed && !allowed.some((a) => modelShortLabel(a) === modelShortLabel(requested))) {
67
+ return { model: current, denied: true };
68
+ }
69
+ const fmt = opts.format ?? ((from: string, to: string) => `_[switching from ${from} to ${to}]_\n\n`);
70
+ const changed = prior !== undefined && modelShortLabel(prior) !== modelShortLabel(requested);
71
+ return {
72
+ model: requested,
73
+ denied: false,
74
+ notice: changed ? fmt(modelShortLabel(prior!), modelShortLabel(requested)) : undefined,
75
+ };
76
+ }
@@ -0,0 +1,24 @@
1
+ import path from 'node:path';
2
+ import { readdirSync } from 'node:fs';
3
+
4
+ function resolveDataDir(): string {
5
+ if (process.env.DATA_DIR) return process.env.DATA_DIR;
6
+ const root = process.cwd();
7
+ const hasNamed = readdirSync(root).some(f => f.startsWith('data-'));
8
+ if (hasNamed) {
9
+ throw new Error(
10
+ `DATA_DIR not set but named data dirs exist (data-*). ` +
11
+ `Run via 'bun run dev <env>' or set DATA_DIR explicitly.`
12
+ );
13
+ }
14
+ return path.resolve(root, 'data');
15
+ }
16
+
17
+ export const DATA_DIR = resolveDataDir();
18
+ export const dataPath = (...segments: string[]) => path.join(DATA_DIR, ...segments);
19
+
20
+ // The Shraga app root (where `defaults/` lives and the agent's project filesystem is rooted).
21
+ // Derived from THIS module's stable location — `src/server/paths.ts` → repo root is two dirs up — so
22
+ // it's correct even for code loaded from an overlay checkout in a different directory (where
23
+ // `import.meta.dirname`-relative math would resolve to the overlay, not the app).
24
+ export const PROJECT_ROOT = path.resolve(import.meta.dirname, '..', '..');
@@ -0,0 +1,175 @@
1
+ // Proactive Slack poll / directed-question state. The mcp-slack-use `post_slack_poll`
2
+ // tool posts the interactive message; shraga owns everything after: vote state, live
3
+ // tally updates (chat.update), closing on deadline/quorum/first-answer, and waking the
4
+ // originating agent session once with the result ("close then report").
5
+ //
6
+ // Decoupled from claude.ts via injected runners (see initPolls) to avoid an import cycle.
7
+ import { mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
8
+ import path from 'node:path';
9
+ import { dataPath } from './paths.ts';
10
+ import { appendMessage, getSession, type ConvBlock } from './sessions.ts';
11
+ import { addUnread } from './unread.ts';
12
+ import { postMessage, slackPost, getUserName, buildPollBlocks, type PollSpec } from './slack/api.ts';
13
+ import { findSlackSessionBySessionId } from './slack/sessions.ts';
14
+
15
+ const PREFIX = '[polls]';
16
+
17
+ export interface PollRecord extends PollSpec {
18
+ channel: string;
19
+ ts: string;
20
+ useUserToken?: boolean;
21
+ votes: Record<string, number[]>; // userId -> chosen option indices
22
+ status: 'open' | 'closed';
23
+ deadlineAt?: number;
24
+ quorum?: number;
25
+ sessionId: string; // originating session to report back to
26
+ uid: string;
27
+ userEmail?: string;
28
+ createdAt: number;
29
+ }
30
+
31
+ // ── IoC: injected by index.ts at startup to avoid a claude.ts <-> polls.ts cycle ──
32
+ type TurnRunner = (args: { prompt: string; sessionId: string; uid: string; userEmail?: string }) => Promise<ConvBlock[]>;
33
+ let runTurn: TurnRunner | null = null;
34
+ let broadcastFn: ((ev: object) => void) | null = null;
35
+
36
+ export function initPolls(deps: { runTurn: TurnRunner; broadcast: (ev: object) => void }): void {
37
+ runTurn = deps.runTurn;
38
+ broadcastFn = deps.broadcast;
39
+ setInterval(sweep, 60_000);
40
+ console.log(`${PREFIX} sweeper started`);
41
+ }
42
+
43
+ const PRUNE_AFTER_MS = 7 * 24 * 60 * 60_000; // delete closed poll files after 7 days
44
+
45
+ /** Close polls past their deadline; prune long-closed poll files. */
46
+ function sweep(): void {
47
+ const now = Date.now();
48
+ let files: string[];
49
+ try { files = readdirSync(dir()).filter((f) => f.endsWith('.json')); } catch { return; }
50
+ for (const f of files) {
51
+ let p: PollRecord | null = null;
52
+ try { p = JSON.parse(readFileSync(path.join(dir(), f), 'utf-8')) as PollRecord; } catch { continue; }
53
+ if (!p) continue;
54
+ if (p.status === 'open' && p.deadlineAt && now >= p.deadlineAt) {
55
+ closePoll(p.pollId, 'deadline').catch((e) => console.error(`${PREFIX} sweep close failed:`, (e as Error)?.message));
56
+ } else if (p.status === 'closed' && now - p.createdAt > PRUNE_AFTER_MS) {
57
+ try { rmSync(path.join(dir(), f)); } catch { /* ignore */ }
58
+ }
59
+ }
60
+ }
61
+
62
+ // ── Storage ───────────────────────────────────────────────────────────────────
63
+ const dir = (): string => { const d = dataPath('polls'); mkdirSync(d, { recursive: true }); return d; };
64
+ const file = (id: string): string => path.join(dir(), `${id}.json`);
65
+
66
+ export function loadPoll(id: string): PollRecord | null {
67
+ try { return JSON.parse(readFileSync(file(id), 'utf-8')) as PollRecord; } catch { return null; }
68
+ }
69
+ function save(p: PollRecord): void { writeFileSync(file(p.pollId), JSON.stringify(p, null, 2)); }
70
+
71
+ const toSpec = (p: PollRecord): PollSpec => ({ pollId: p.pollId, title: p.title, options: p.options, kind: p.kind, multi: p.multi, targetUser: p.targetUser });
72
+ function tallies(p: PollRecord): number[] {
73
+ const t = p.options.map(() => 0);
74
+ for (const idxs of Object.values(p.votes)) for (const i of idxs) if (t[i] !== undefined) t[i]++;
75
+ return t;
76
+ }
77
+ const voterCount = (p: PollRecord): number => Object.keys(p.votes).length;
78
+
79
+ async function rerender(p: PollRecord, closed = false): Promise<void> {
80
+ await slackPost('chat.update', { channel: p.channel, ts: p.ts, text: p.title, blocks: buildPollBlocks(toSpec(p), tallies(p), voterCount(p), closed) }, p.useUserToken)
81
+ .catch((e) => console.error(`${PREFIX} rerender failed:`, (e as Error)?.message));
82
+ }
83
+
84
+ // ── Registration (called from claude.ts on a post_slack_poll tool result) ───────
85
+ export interface RegisterPollInput {
86
+ pollId: string; channel: string; ts: string; title: string;
87
+ options: { label: string; description?: string }[];
88
+ kind: 'poll' | 'question'; multi?: boolean; targetUser?: string;
89
+ deadlineMinutes?: number; quorum?: number; useUserToken?: boolean;
90
+ sessionId: string; uid: string; userEmail?: string;
91
+ }
92
+
93
+ export function registerPoll(i: RegisterPollInput): void {
94
+ const p: PollRecord = {
95
+ pollId: i.pollId, title: i.title, options: i.options, kind: i.kind, multi: i.multi, targetUser: i.targetUser,
96
+ channel: i.channel, ts: i.ts, useUserToken: i.useUserToken,
97
+ votes: {}, status: 'open', createdAt: Date.now(),
98
+ deadlineAt: i.deadlineMinutes ? Date.now() + i.deadlineMinutes * 60_000 : undefined,
99
+ quorum: i.quorum, sessionId: i.sessionId, uid: i.uid, userEmail: i.userEmail,
100
+ };
101
+ save(p);
102
+ console.log(`${PREFIX} registered ${p.pollId} kind=${p.kind} ch=${p.channel} deadline=${i.deadlineMinutes ?? '-'}m quorum=${i.quorum ?? '-'}`);
103
+ }
104
+
105
+ // ── Interactivity (called from slack/questions.ts) ──────────────────────────────
106
+ export async function handlePollVote(pollId: string, userId: string, optIdx: number): Promise<void> {
107
+ const p = loadPoll(pollId);
108
+ if (!p || p.status === 'closed') return;
109
+ // Directed question: only the addressed user may answer.
110
+ if (p.kind === 'question' && p.targetUser && userId !== p.targetUser) return;
111
+
112
+ const cur = new Set(p.votes[userId] ?? []);
113
+ if (p.kind === 'question' || !p.multi) {
114
+ if (cur.has(optIdx)) cur.delete(optIdx); else { cur.clear(); cur.add(optIdx); }
115
+ } else {
116
+ cur.has(optIdx) ? cur.delete(optIdx) : cur.add(optIdx);
117
+ }
118
+ if (cur.size) p.votes[userId] = [...cur]; else delete p.votes[userId];
119
+ save(p);
120
+
121
+ const complete = p.kind === 'question'
122
+ ? (p.targetUser ? (p.votes[p.targetUser]?.length ?? 0) > 0 : voterCount(p) > 0)
123
+ : (p.quorum ? voterCount(p) >= p.quorum : false);
124
+ if (complete) await closePoll(pollId, 'quorum'); else await rerender(p, false);
125
+ }
126
+
127
+ export async function handlePollClose(pollId: string): Promise<void> {
128
+ await closePoll(pollId, 'manual');
129
+ }
130
+
131
+ async function closePoll(pollId: string, reason: 'deadline' | 'quorum' | 'manual'): Promise<void> {
132
+ const p = loadPoll(pollId);
133
+ if (!p || p.status === 'closed') return;
134
+ p.status = 'closed';
135
+ save(p);
136
+ await rerender(p, true);
137
+ console.log(`${PREFIX} closed ${pollId} reason=${reason} voters=${voterCount(p)}`);
138
+ await wakeAgent(p, reason).catch((e) => console.error(`${PREFIX} wake failed:`, (e as Error)?.message));
139
+ }
140
+
141
+ // ── Close then report: wake the originating session once with the tally ─────────
142
+ async function wakeAgent(p: PollRecord, reason: string): Promise<void> {
143
+ if (!runTurn) { console.warn(`${PREFIX} no turn runner; skipping wake for ${p.pollId}`); return; }
144
+ if (!getSession(p.sessionId)) { console.warn(`${PREFIX} session ${p.sessionId} gone; skipping wake`); return; }
145
+
146
+ // Per-option voters, with Slack user ids resolved to display names (cached).
147
+ const votersByOption: string[][] = p.options.map(() => []);
148
+ for (const [userId, idxs] of Object.entries(p.votes)) for (const i of idxs) if (votersByOption[i]) votersByOption[i].push(userId);
149
+ const nameCache = new Map<string, string>();
150
+ const resolveName = async (uid: string): Promise<string> => {
151
+ if (nameCache.has(uid)) return nameCache.get(uid)!;
152
+ const n = (await getUserName(uid).catch(() => null)) || uid;
153
+ nameCache.set(uid, n);
154
+ return n;
155
+ };
156
+ const lines = (await Promise.all(p.options.map(async (o, i) => {
157
+ const names = await Promise.all(votersByOption[i].map(resolveName));
158
+ const c = votersByOption[i].length;
159
+ return `- ${o.label}: ${c} vote${c === 1 ? '' : 's'}${names.length ? ` (${names.join(', ')})` : ''}`;
160
+ }))).join('\n');
161
+ const headline = p.kind === 'question' ? 'Your question was answered' : `Your poll closed (${reason})`;
162
+ const prompt = `[Poll result] ${headline}. Title: "${p.title}". ${voterCount(p)} participant(s).\n${lines}\n\nFollow up appropriately (summarize, take the next action, or notify the relevant people). Do not re-post the poll.`;
163
+
164
+ appendMessage(p.sessionId, { id: crypto.randomUUID(), role: 'user', blocks: [{ type: 'text', text: prompt }], channel: 'poll' });
165
+ broadcastFn?.({ type: 'session_messages_changed', sessionId: p.sessionId });
166
+
167
+ const blocks = await runTurn({ prompt, sessionId: p.sessionId, uid: p.uid, userEmail: p.userEmail });
168
+ if (!blocks.length) return;
169
+ appendMessage(p.sessionId, { id: crypto.randomUUID(), role: 'assistant', blocks });
170
+ broadcastFn?.({ type: 'session_messages_changed', sessionId: p.sessionId });
171
+ const text = blocks.filter((b): b is { type: 'text'; text: string } => b.type === 'text').map((b) => b.text).join('\n\n').trim();
172
+ addUnread(p.uid, p.sessionId, text.slice(0, 120) || 'Poll closed', 'proactive', p.title);
173
+ const slack = findSlackSessionBySessionId(p.sessionId);
174
+ if (slack && text) await postMessage(slack.channel, text, slack.threadTs, slack.useUserToken).catch((e) => console.error(`${PREFIX} slack deliver failed:`, (e as Error)?.message));
175
+ }
@@ -0,0 +1,113 @@
1
+ // APNs (iOS) sender — token-based (.p8) auth over HTTP/2.
2
+ //
3
+ // CRITICAL: this MUST use node:http2, NOT fetch. Bun's fetch returns
4
+ // `Malformed_HTTP_Response` against api.push.apple.com and never reaches Apple
5
+ // (mocked unit tests still pass — so this path is only trustworthy when verified
6
+ // with a LIVE dummy-token probe → expect HTTP 400 BadDeviceToken).
7
+ //
8
+ // Ported from appwrap/examples/push-relay/src/server.ts; adds a pooled
9
+ // ClientHttp2Session and dead-token classification.
10
+ import { connect, type ClientHttp2Session } from 'node:http2';
11
+ import { createSign } from 'node:crypto';
12
+
13
+ const env = (k: string) => process.env[k] || '';
14
+
15
+ export interface PushMessage {
16
+ title: string;
17
+ body: string;
18
+ data?: Record<string, unknown>;
19
+ }
20
+
21
+ export interface ApnsResult {
22
+ status: number;
23
+ id?: string;
24
+ reason?: string;
25
+ }
26
+
27
+ /** True when APNs credentials are present (independent of PUSH_ENABLED). */
28
+ export function apnsConfigured(): boolean {
29
+ return !!(env('APNS_KEY_P8_B64') && env('APNS_KEY_ID') && env('APNS_TEAM_ID'));
30
+ }
31
+
32
+ export function apnsTopic(): string {
33
+ return env('APNS_TOPIC');
34
+ }
35
+
36
+ function apnsHost(): string {
37
+ return env('APNS_ENV') === 'production' ? 'https://api.push.apple.com' : 'https://api.sandbox.push.apple.com';
38
+ }
39
+
40
+ const b64url = (o: object) => Buffer.from(JSON.stringify(o)).toString('base64url');
41
+
42
+ // ── ES256 provider JWT (kid=APNS_KEY_ID, iss=APNS_TEAM_ID), cached < 50min ──
43
+ let jwtCache: { jwt: string; at: number } | null = null;
44
+ function apnsJwt(): string {
45
+ if (jwtCache && Date.now() - jwtCache.at < 50 * 60_000) return jwtCache.jwt;
46
+ const key = Buffer.from(env('APNS_KEY_P8_B64'), 'base64').toString('utf8');
47
+ const iat = Math.floor(Date.now() / 1000);
48
+ const head = b64url({ alg: 'ES256', kid: env('APNS_KEY_ID') });
49
+ const body = b64url({ iss: env('APNS_TEAM_ID'), iat });
50
+ const sig = createSign('SHA256').update(`${head}.${body}`).sign({ key, dsaEncoding: 'ieee-p1363' }).toString('base64url');
51
+ jwtCache = { jwt: `${head}.${body}.${sig}`, at: Date.now() };
52
+ return jwtCache.jwt;
53
+ }
54
+
55
+ // ── Pooled HTTP/2 session (one per host, reconnect on close/error) ──
56
+ let session: ClientHttp2Session | null = null;
57
+ function getSession(): ClientHttp2Session {
58
+ if (session && !session.closed && !session.destroyed) return session;
59
+ const s = connect(apnsHost());
60
+ s.on('error', (e: any) => {
61
+ console.error('[push] apns session error:', e?.message || e);
62
+ if (session === s) session = null;
63
+ });
64
+ s.on('close', () => { if (session === s) session = null; });
65
+ session = s;
66
+ return s;
67
+ }
68
+
69
+ function parseReason(data: string): string | undefined {
70
+ if (!data) return undefined;
71
+ try { return JSON.parse(data).reason; } catch { return data.slice(0, 120); }
72
+ }
73
+
74
+ /** A 410, or BadDeviceToken / Unregistered / DeviceTokenNotForTopic → the token is dead and should be pruned. */
75
+ export function isApnsTokenDead(status: number, reason?: string): boolean {
76
+ if (status === 410) return true;
77
+ return reason === 'BadDeviceToken' || reason === 'Unregistered' || reason === 'DeviceTokenNotForTopic';
78
+ }
79
+
80
+ export function sendApns(token: string, topic: string, msg: PushMessage): Promise<ApnsResult> {
81
+ return new Promise((resolve) => {
82
+ let settled = false;
83
+ const done = (r: ApnsResult) => { if (settled) return; settled = true; resolve(r); };
84
+
85
+ let client: ClientHttp2Session;
86
+ try { client = getSession(); } catch (e: any) { return done({ status: 0, reason: String(e?.message || e) }); }
87
+
88
+ const payload = JSON.stringify({ aps: { alert: { title: msg.title, body: msg.body }, sound: 'default' }, ...(msg.data || {}) });
89
+ let req;
90
+ try {
91
+ req = client.request({
92
+ ':method': 'POST',
93
+ ':path': `/3/device/${token}`,
94
+ authorization: `bearer ${apnsJwt()}`,
95
+ 'apns-topic': topic,
96
+ 'apns-push-type': 'alert',
97
+ 'apns-priority': '10',
98
+ 'content-type': 'application/json',
99
+ });
100
+ } catch (e: any) {
101
+ session = null;
102
+ return done({ status: 0, reason: String(e?.message || e) });
103
+ }
104
+
105
+ let status = 0, id: string | undefined, data = '';
106
+ req.on('response', (h: any) => { status = h[':status']; id = h['apns-id']; });
107
+ req.on('data', (c: Buffer) => (data += c));
108
+ // Resolve at `end` so a non-200 surfaces APNs's `reason` (e.g. BadDeviceToken) instead of losing it.
109
+ req.on('end', () => done({ status, id, reason: parseReason(data) }));
110
+ req.on('error', (e: any) => { session = null; done({ status: 0, reason: String(e?.message || e) }); });
111
+ req.end(payload);
112
+ });
113
+ }