@portel/photon 1.23.0 → 1.24.0

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 (63) hide show
  1. package/README.md +66 -0
  2. package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
  3. package/dist/auto-ui/streamable-http-transport.js +262 -18
  4. package/dist/auto-ui/streamable-http-transport.js.map +1 -1
  5. package/dist/beam.bundle.js +58287 -56177
  6. package/dist/beam.bundle.js.map +4 -4
  7. package/dist/capability-negotiator.d.ts +9 -0
  8. package/dist/capability-negotiator.d.ts.map +1 -1
  9. package/dist/capability-negotiator.js +14 -0
  10. package/dist/capability-negotiator.js.map +1 -1
  11. package/dist/cli/commands/claim.d.ts +17 -0
  12. package/dist/cli/commands/claim.d.ts.map +1 -0
  13. package/dist/cli/commands/claim.js +124 -0
  14. package/dist/cli/commands/claim.js.map +1 -0
  15. package/dist/cli/commands/run.d.ts.map +1 -1
  16. package/dist/cli/commands/run.js +2 -0
  17. package/dist/cli/commands/run.js.map +1 -1
  18. package/dist/cli/index.d.ts.map +1 -1
  19. package/dist/cli/index.js +2 -0
  20. package/dist/cli/index.js.map +1 -1
  21. package/dist/daemon/claims.d.ts +108 -0
  22. package/dist/daemon/claims.d.ts.map +1 -0
  23. package/dist/daemon/claims.js +245 -0
  24. package/dist/daemon/claims.js.map +1 -0
  25. package/dist/daemon/client.d.ts.map +1 -1
  26. package/dist/daemon/client.js +15 -29
  27. package/dist/daemon/client.js.map +1 -1
  28. package/dist/daemon/cron.d.ts +36 -0
  29. package/dist/daemon/cron.d.ts.map +1 -0
  30. package/dist/daemon/cron.js +216 -0
  31. package/dist/daemon/cron.js.map +1 -0
  32. package/dist/daemon/schedule-loader.d.ts +76 -0
  33. package/dist/daemon/schedule-loader.d.ts.map +1 -0
  34. package/dist/daemon/schedule-loader.js +124 -0
  35. package/dist/daemon/schedule-loader.js.map +1 -0
  36. package/dist/daemon/server.js +76 -226
  37. package/dist/daemon/server.js.map +1 -1
  38. package/dist/deploy/cloudflare.d.ts.map +1 -1
  39. package/dist/deploy/cloudflare.js +68 -3
  40. package/dist/deploy/cloudflare.js.map +1 -1
  41. package/dist/loader.d.ts +22 -1
  42. package/dist/loader.d.ts.map +1 -1
  43. package/dist/loader.js +162 -7
  44. package/dist/loader.js.map +1 -1
  45. package/dist/photon-cli-runner.d.ts.map +1 -1
  46. package/dist/photon-cli-runner.js +17 -0
  47. package/dist/photon-cli-runner.js.map +1 -1
  48. package/dist/server.d.ts +10 -0
  49. package/dist/server.d.ts.map +1 -1
  50. package/dist/server.js +50 -1
  51. package/dist/server.js.map +1 -1
  52. package/dist/shared/memory-sqlite.d.ts +37 -0
  53. package/dist/shared/memory-sqlite.d.ts.map +1 -0
  54. package/dist/shared/memory-sqlite.js +143 -0
  55. package/dist/shared/memory-sqlite.js.map +1 -0
  56. package/dist/shared/sqlite-runtime.d.ts.map +1 -1
  57. package/dist/shared/sqlite-runtime.js +12 -2
  58. package/dist/shared/sqlite-runtime.js.map +1 -1
  59. package/dist/tsx-compiler.d.ts.map +1 -1
  60. package/dist/tsx-compiler.js +18 -1
  61. package/dist/tsx-compiler.js.map +1 -1
  62. package/package.json +6 -2
  63. package/templates/cloudflare/worker.ts.template +44 -73
package/README.md CHANGED
@@ -280,6 +280,9 @@ Things you don't build because Photon handles them:
280
280
  | **Cross-photon calls** | `this.call()` invokes another photon's methods |
281
281
  | **Real-time events** | `this.emit()` fires named events to the browser UI with zero wiring |
282
282
  | **Live rendering** | `this.render()` pushes formatted output to CLI and Beam in real time |
283
+ | **Delegated LLM** | `this.sample()` asks the driving agent's model to generate text — no API key, agent pays |
284
+ | **Inline confirm / input** | `this.confirm()` and `this.elicit()` route through the client's native UI (Beam dialog, Claude prompt) |
285
+ | **Scoped remote access** | `photon claim` generates a short-lived code to scope a remote MCP session to one directory |
283
286
  | **Standalone binaries** | `photon build` compiles any photon to a single executable via Bun |
284
287
  | **Dependency management** | `@dependencies` auto-installs npm packages on first run |
285
288
 
@@ -327,6 +330,69 @@ The same pattern applies beyond games: approval workflows where a human reviews
327
330
 
328
331
  ---
329
332
 
333
+ ## MCP Primitives on `this`
334
+
335
+ The MCP protocol's user-facing primitives are surfaced as plain methods
336
+ on every photon instance — no decorators, no capability flags, no SDK
337
+ imports. The runtime routes each call through whichever surface the
338
+ request arrived on (Beam, Claude Desktop, Cursor, CLI).
339
+
340
+ ```typescript
341
+ export default class Editor {
342
+ async summarize(params: { text: string }) {
343
+ // Ask the driving agent's LLM. No API key. Agent pays.
344
+ return await this.sample({
345
+ prompt: `Summarize in one sentence:\n\n${params.text}`,
346
+ maxTokens: 128,
347
+ });
348
+ }
349
+
350
+ async deploy() {
351
+ if (!(await this.confirm('Ship to production?'))) return;
352
+ const env = await this.elicit<string>({
353
+ ask: 'select',
354
+ message: 'Which environment?',
355
+ options: ['staging', 'prod'],
356
+ });
357
+ await this.run(env);
358
+ }
359
+ }
360
+ ```
361
+
362
+ | Primitive | What it does |
363
+ |---|---|
364
+ | `await this.sample({ prompt })` | Delegates LLM generation to the caller's model via MCP sampling |
365
+ | `await this.confirm(question)` | Yes/no prompt — returns `boolean` |
366
+ | `await this.elicit(params)` | Arbitrary input (text, select, form, file, etc.) |
367
+ | `this.status(msg)` / `this.progress(v)` | Live feedback during long work |
368
+
369
+ Full reference: [`docs/reference/MCP-PRIMITIVES.md`](docs/reference/MCP-PRIMITIVES.md).
370
+
371
+ ---
372
+
373
+ ## Remote Access: Claim Codes
374
+
375
+ By default every installed photon is visible to every connected MCP
376
+ client. When you want to pair a *remote* agent with a *subset* of your
377
+ photons — your phone driving Beam, a teammate reviewing one project,
378
+ a CI agent scoped to a single directory — generate a claim code:
379
+
380
+ ```bash
381
+ $ photon claim --scope /workspace/proj --ttl 4h --label "phone"
382
+ ✓ Claim code: R3K-9QZ
383
+ Scope: /workspace/proj
384
+ Expires in: 4h
385
+ ```
386
+
387
+ The remote client presents the code as the `Mcp-Claim-Code` header on
388
+ its MCP session. `tools/list` then only exposes photons whose source
389
+ lives under that directory. Sessions without a code keep full access —
390
+ the feature is strictly opt-in.
391
+
392
+ Full reference: [`docs/reference/CLAIM-CODES.md`](docs/reference/CLAIM-CODES.md).
393
+
394
+ ---
395
+
330
396
  ## Marketplace
331
397
 
332
398
  32 photons ready to install: databases, APIs, developer tools, and more.
@@ -1 +1 @@
1
- {"version":3,"file":"streamable-http-transport.d.ts","sourceRoot":"","sources":["../../src/auto-ui/streamable-http-transport.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"streamable-http-transport.d.ts","sourceRoot":"","sources":["../../src/auto-ui/streamable-http-transport.ts"],"names":[],"mappings":"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"}
@@ -38,6 +38,7 @@ import { createTask, getTask, updateTask, listTasks, registerController, unregis
38
38
  import { toWireFormat, relatedTaskMeta, TERMINAL_STATES } from '../tasks/types.js';
39
39
  import { runTaskExecution, resolveTaskInput, waitForTerminalOrInput } from '../tasks/executor.js';
40
40
  import { generateAgentCard } from '../a2a/card-generator.js';
41
+ import { isPathInScope } from '../daemon/claims.js';
41
42
  // ════════════════════════════════════════════════════════════════════════════════
42
43
  // JWT HELPERS
43
44
  // ════════════════════════════════════════════════════════════════════════════════
@@ -73,6 +74,71 @@ function decodeJWTCaller(authHeader) {
73
74
  // ════════════════════════════════════════════════════════════════════════════════
74
75
  const sessions = new Map();
75
76
  const pendingElicitations = new Map();
77
+ const pendingServerRequests = new Map();
78
+ let nextServerRequestId = 1;
79
+ /**
80
+ * Send a JSON-RPC request to a Beam session over its SSE stream and
81
+ * await the reply. Backs photon-facing providers (sampling, future
82
+ * roots/list) that route through the human at the browser.
83
+ *
84
+ * 30-min timeout accommodates the sampling-modal's serial queue:
85
+ * a queued request can sit behind an open modal for the user's full
86
+ * read+reply time, so 5 min would let entries expire off-screen.
87
+ */
88
+ function requestSession(sessionId, method, params, timeoutMs = 30 * 60_000) {
89
+ return new Promise((resolve, reject) => {
90
+ const session = sessions.get(sessionId);
91
+ if (!session || !session.sseResponse || session.sseResponse.writableEnded) {
92
+ reject(new Error(`requestSession: session ${sessionId} has no live SSE stream — the ` +
93
+ `browser must be connected for server→client requests to work.`));
94
+ return;
95
+ }
96
+ const id = `srv-${nextServerRequestId++}`;
97
+ const timer = setTimeout(() => {
98
+ pendingServerRequests.delete(id);
99
+ reject(new Error(`requestSession: ${method} timed out after ${timeoutMs}ms`));
100
+ }, timeoutMs);
101
+ pendingServerRequests.set(id, { resolve, reject, sessionId, timer });
102
+ const payload = `data: ${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n\n`;
103
+ try {
104
+ session.sseResponse.write(payload);
105
+ }
106
+ catch (err) {
107
+ clearTimeout(timer);
108
+ pendingServerRequests.delete(id);
109
+ reject(err instanceof Error ? err : new Error(String(err)));
110
+ }
111
+ });
112
+ }
113
+ /**
114
+ * Build a sampling provider for a specific Beam session — the
115
+ * person at the browser plays the role of the LLM. The provider
116
+ * returns a `CreateMessageResult` shape with `model: 'human@beam'`
117
+ * so the photon can tell (via the returned model field) that the
118
+ * response came from a person rather than a real model.
119
+ */
120
+ function makeHumanSamplingProvider(sessionId) {
121
+ return async (params) => {
122
+ const result = (await requestSession(sessionId, 'sampling/createMessage', params));
123
+ // Defensive normalisation: some clients return just a string;
124
+ // wrap it in the canonical CreateMessageResult shape so photon-core
125
+ // can index into content[0].text without extra checks.
126
+ if (typeof result === 'string') {
127
+ return {
128
+ role: 'assistant',
129
+ content: { type: 'text', text: result },
130
+ model: 'human@beam',
131
+ stopReason: 'endTurn',
132
+ };
133
+ }
134
+ return {
135
+ role: result?.role ?? 'assistant',
136
+ content: result?.content ?? { type: 'text', text: '' },
137
+ model: result?.model ?? 'human@beam',
138
+ stopReason: result?.stopReason ?? 'endTurn',
139
+ };
140
+ };
141
+ }
76
142
  const APPROVALS_DIR = join(homedir(), '.photon', 'state');
77
143
  // Simple async mutex for file operations
78
144
  function createMutex() {
@@ -451,6 +517,19 @@ function buildToolResult(result, methodInfo) {
451
517
  }
452
518
  return toolResult;
453
519
  }
520
+ /**
521
+ * Is the given task visible to this session under its claim scope?
522
+ * Out-of-scope tasks AND tasks whose photon has been removed are
523
+ * both hidden, so a stale task can't leak info to a scoped caller.
524
+ */
525
+ function isTaskInScope(task, scopeDir, photons) {
526
+ if (!scopeDir)
527
+ return true;
528
+ const info = photons.find((p) => p.name === task.photon);
529
+ if (!info)
530
+ return false;
531
+ return isPathInScope(info.path, scopeDir);
532
+ }
454
533
  const handlers = {
455
534
  // ─────────────────────────────────────────────────────────────────────────────
456
535
  // Lifecycle
@@ -777,8 +856,16 @@ const handlers = {
777
856
  // ─────────────────────────────────────────────────────────────────────────────
778
857
  'tools/list': async (req, session, ctx) => {
779
858
  const tools = [];
859
+ // Claim-code scoping: when the session presented a valid claim on
860
+ // initialize, only photons whose source file lives under that
861
+ // directory are visible. Unscoped sessions keep the prior behavior
862
+ // (every configured photon is listed).
863
+ const scopeDir = session.claimScopeDir;
864
+ const visiblePhotons = scopeDir
865
+ ? ctx.photons.filter((p) => isPathInScope(p.path, scopeDir))
866
+ : ctx.photons;
780
867
  // Add configured photon methods as tools
781
- for (const photon of ctx.photons) {
868
+ for (const photon of visiblePhotons) {
782
869
  if (!photon.configured || !photon.methods)
783
870
  continue;
784
871
  for (const method of photon.methods) {
@@ -826,7 +913,7 @@ const handlers = {
826
913
  }
827
914
  }
828
915
  // Add runtime-injected instance tools for stateful photons
829
- for (const photon of ctx.photons) {
916
+ for (const photon of visiblePhotons) {
830
917
  if (!photon.configured || !photon.stateful)
831
918
  continue;
832
919
  tools.push({
@@ -1136,6 +1223,13 @@ const handlers = {
1136
1223
  },
1137
1224
  'tools/call': async (req, session, ctx) => {
1138
1225
  const { name, arguments: args } = req.params;
1226
+ // MCP spec: if the caller supplied `_meta.progressToken`, every
1227
+ // notifications/progress we emit for this request MUST echo that
1228
+ // token so the client can correlate progress events with the
1229
+ // specific in-flight request. Falling back to a synthetic token
1230
+ // stranded progress notifications — clients filtered them out
1231
+ // because no listener was registered for the synthetic key.
1232
+ const clientProgressToken = req.params?._meta?.progressToken;
1139
1233
  // Handle beam system tools
1140
1234
  if (name === 'beam/configure') {
1141
1235
  return handleBeamConfigure(req, ctx, args || {});
@@ -1189,6 +1283,30 @@ const handlers = {
1189
1283
  const methodName = name.slice(slashIndex + 1);
1190
1284
  // Per-photon auth check: if this photon requires auth but caller is anonymous, reject
1191
1285
  const targetPhoton = ctx.photons.find((p) => p.name === serverName);
1286
+ // Claim-code scope enforcement: filtering tools/list alone is not a
1287
+ // gate — a caller that knows a tool name (cached from before the
1288
+ // claim was scoped, or inferred) could still invoke it via
1289
+ // tools/call. Every call therefore re-checks scope against the
1290
+ // session's `claimScopeDir`. Unscoped sessions bypass this check.
1291
+ if (session.claimScopeDir) {
1292
+ if (!targetPhoton || !isPathInScope(targetPhoton.path, session.claimScopeDir)) {
1293
+ return {
1294
+ jsonrpc: '2.0',
1295
+ id: req.id,
1296
+ result: {
1297
+ content: [
1298
+ {
1299
+ type: 'text',
1300
+ text: `Tool ${name} is not available in the current claim scope. ` +
1301
+ `The claim code presented on this session only grants access to photons ` +
1302
+ `under ${session.claimScopeDir}.`,
1303
+ },
1304
+ ],
1305
+ isError: true,
1306
+ },
1307
+ };
1308
+ }
1309
+ }
1192
1310
  if (targetPhoton?.configured && targetPhoton.auth === 'required') {
1193
1311
  if (!ctx.caller || ctx.caller.anonymous) {
1194
1312
  return {
@@ -1504,12 +1622,17 @@ const handlers = {
1504
1622
  const task = createTask(photonName, methodName, args, ttl);
1505
1623
  const controller = new AbortController();
1506
1624
  registerController(task.id, controller);
1507
- // Build execution function that the executor will run
1625
+ // Build execution function that the executor will run.
1626
+ // Same human-sampling provider shape as the sync path — scoped
1627
+ // to the session that kicked off the task so the modal appears
1628
+ // in the right browser tab.
1508
1629
  const executeFn = async (inputProvider, outputHandler) => {
1509
1630
  if (ctx.loader) {
1631
+ const samplingProvider = makeHumanSamplingProvider(session.id);
1510
1632
  return ctx.loader.executeTool(mcp, methodName, args || {}, {
1511
1633
  outputHandler,
1512
1634
  inputProvider,
1635
+ samplingProvider,
1513
1636
  caller: ctx.caller,
1514
1637
  });
1515
1638
  }
@@ -1550,6 +1673,12 @@ const handlers = {
1550
1673
  const outputHandler = (yieldValue) => {
1551
1674
  if (!ctx.broadcast)
1552
1675
  return;
1676
+ // Echo the caller's progressToken when supplied so the client
1677
+ // can route notifications back to the originating panel. Fall
1678
+ // back to the synthetic `progress_<photon>_<method>` only when
1679
+ // the caller didn't send one (e.g. server-initiated task
1680
+ // progress with no user request to correlate against).
1681
+ const progressToken = clientProgressToken ?? `progress_${photonName}_${methodName}`;
1553
1682
  // Forward progress events as MCP notifications
1554
1683
  if (yieldValue?.emit === 'progress') {
1555
1684
  const rawValue = typeof yieldValue.value === 'number' ? yieldValue.value : 0;
@@ -1558,7 +1687,7 @@ const handlers = {
1558
1687
  jsonrpc: '2.0',
1559
1688
  method: 'notifications/progress',
1560
1689
  params: {
1561
- progressToken: `progress_${photonName}_${methodName}`,
1690
+ progressToken,
1562
1691
  progress,
1563
1692
  total: 100,
1564
1693
  message: yieldValue.message || null,
@@ -1572,7 +1701,7 @@ const handlers = {
1572
1701
  jsonrpc: '2.0',
1573
1702
  method: 'notifications/progress',
1574
1703
  params: {
1575
- progressToken: `progress_${photonName}_${methodName}`,
1704
+ progressToken,
1576
1705
  progress: 0,
1577
1706
  total: 100,
1578
1707
  message: yieldValue.message || '',
@@ -1747,13 +1876,23 @@ const handlers = {
1747
1876
  });
1748
1877
  };
1749
1878
  // Use loader.executeTool if available (sets up execution context for this.emit())
1750
- // Fall back to direct method call for backward compatibility
1879
+ // Fall back to direct method call for backward compatibility.
1880
+ //
1881
+ // Build a samplingProvider that forwards `sampling/createMessage`
1882
+ // to the Beam session driving this call. The browser's request
1883
+ // handler (src/auto-ui/frontend/components/sampling-modal.ts)
1884
+ // pops a modal, the human types a response, and that text flows
1885
+ // back as the photon's `this.sample()` return value. Declaring
1886
+ // `sampling: {}` on the client without this provider would let
1887
+ // the photon hang — the earlier codex P1 finding.
1751
1888
  let result;
1752
1889
  const startTime = Date.now();
1753
1890
  if (ctx.loader) {
1891
+ const samplingProvider = makeHumanSamplingProvider(session.id);
1754
1892
  result = await ctx.loader.executeTool(mcp, methodName, args || {}, {
1755
1893
  outputHandler,
1756
1894
  inputProvider,
1895
+ samplingProvider,
1757
1896
  caller: ctx.caller,
1758
1897
  });
1759
1898
  }
@@ -2086,6 +2225,24 @@ const handlers = {
2086
2225
  error: { code: -32602, message: 'Missing required params: photon, method' },
2087
2226
  };
2088
2227
  }
2228
+ // Claim-code scope enforcement on the async task API. tools/call
2229
+ // has the same gate at the sync path; without this, a scoped
2230
+ // client could bypass scope by starting work via tasks/create.
2231
+ if (session.claimScopeDir) {
2232
+ const targetInfo = ctx.photons.find((p) => p.name === photonName);
2233
+ if (!targetInfo || !isPathInScope(targetInfo.path, session.claimScopeDir)) {
2234
+ return {
2235
+ jsonrpc: '2.0',
2236
+ id: req.id,
2237
+ error: {
2238
+ code: -32602,
2239
+ message: `Photon ${photonName} is not available in the current claim scope. ` +
2240
+ `The claim code presented on this session only grants access to photons ` +
2241
+ `under ${session.claimScopeDir}.`,
2242
+ },
2243
+ };
2244
+ }
2245
+ }
2089
2246
  const mcp = ctx.photonMCPs.get(photonName);
2090
2247
  if (!mcp?.instance) {
2091
2248
  return {
@@ -2121,7 +2278,7 @@ const handlers = {
2121
2278
  result: { task: toWireFormat(task) },
2122
2279
  };
2123
2280
  },
2124
- 'tasks/get': async (req, _session, _ctx) => {
2281
+ 'tasks/get': async (req, session, ctx) => {
2125
2282
  const { taskId } = req.params;
2126
2283
  if (!taskId) {
2127
2284
  return {
@@ -2131,7 +2288,11 @@ const handlers = {
2131
2288
  };
2132
2289
  }
2133
2290
  const task = getTask(taskId);
2134
- if (!task) {
2291
+ // Scope-gated: a scoped session sees "not found" whether the task
2292
+ // is truly absent or simply out of scope. Collapsing both cases
2293
+ // into the same response avoids leaking existence to callers
2294
+ // that shouldn't see this photon.
2295
+ if (!task || !isTaskInScope(task, session.claimScopeDir, ctx.photons)) {
2135
2296
  return {
2136
2297
  jsonrpc: '2.0',
2137
2298
  id: req.id,
@@ -2140,9 +2301,14 @@ const handlers = {
2140
2301
  }
2141
2302
  return { jsonrpc: '2.0', id: req.id, result: toWireFormat(task) };
2142
2303
  },
2143
- 'tasks/list': async (req, _session, _ctx) => {
2304
+ 'tasks/list': async (req, session, ctx) => {
2144
2305
  const { cursor } = (req.params || {});
2145
- const allTasks = listTasks();
2306
+ let allTasks = listTasks();
2307
+ // Filter scoped sessions before paginating so the cursor indexes
2308
+ // align with what the caller actually sees.
2309
+ if (session.claimScopeDir) {
2310
+ allTasks = allTasks.filter((t) => isTaskInScope(t, session.claimScopeDir, ctx.photons));
2311
+ }
2146
2312
  // Simple pagination: cursor is the offset index
2147
2313
  const offset = cursor ? parseInt(cursor, 10) || 0 : 0;
2148
2314
  const pageSize = 50;
@@ -2157,7 +2323,7 @@ const handlers = {
2157
2323
  },
2158
2324
  };
2159
2325
  },
2160
- 'tasks/cancel': async (req, _session, _ctx) => {
2326
+ 'tasks/cancel': async (req, session, ctx) => {
2161
2327
  const { taskId } = req.params;
2162
2328
  if (!taskId) {
2163
2329
  return {
@@ -2167,7 +2333,7 @@ const handlers = {
2167
2333
  };
2168
2334
  }
2169
2335
  const task = getTask(taskId);
2170
- if (!task) {
2336
+ if (!task || !isTaskInScope(task, session.claimScopeDir, ctx.photons)) {
2171
2337
  return {
2172
2338
  jsonrpc: '2.0',
2173
2339
  id: req.id,
@@ -2201,7 +2367,7 @@ const handlers = {
2201
2367
  };
2202
2368
  }
2203
2369
  const task = getTask(taskId);
2204
- if (!task) {
2370
+ if (!task || !isTaskInScope(task, session.claimScopeDir, ctx.photons)) {
2205
2371
  return {
2206
2372
  jsonrpc: '2.0',
2207
2373
  id: req.id,
@@ -3308,7 +3474,7 @@ export async function handleStreamableHTTP(req, res, options) {
3308
3474
  // CORS headers
3309
3475
  res.setHeader('Access-Control-Allow-Origin', '*');
3310
3476
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
3311
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id, Authorization');
3477
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id, Mcp-Claim-Code, Authorization');
3312
3478
  res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
3313
3479
  // Handle preflight
3314
3480
  if (req.method === 'OPTIONS') {
@@ -3348,6 +3514,39 @@ export async function handleStreamableHTTP(req, res, options) {
3348
3514
  sessionId = url.searchParams.get('sessionId') || undefined;
3349
3515
  }
3350
3516
  const session = getOrCreateSession(sessionId);
3517
+ // Claim-code scoping: if the client presents `Mcp-Claim-Code` (header
3518
+ // or query param for SSE), validate it on EVERY request and stamp
3519
+ // the allowed scopeDir onto the session. Re-validating per request
3520
+ // (rather than once at session init) means revoke + TTL expiry take
3521
+ // effect immediately on the next call — otherwise a scoped session
3522
+ // keeps its access after `photon claim revoke` or TTL, which would
3523
+ // defeat the point of short-lived codes.
3524
+ //
3525
+ // Absent or invalid codes leave the session unscoped (full access) —
3526
+ // claims are strictly additive, never a gate on unclaimed sessions.
3527
+ // See `src/daemon/claims.ts` for the store and the scoping contract.
3528
+ {
3529
+ const rawCode = req.headers['mcp-claim-code'] ||
3530
+ url.searchParams.get('claim') ||
3531
+ undefined;
3532
+ if (rawCode) {
3533
+ try {
3534
+ const { validateClaimSync } = await import('../daemon/claims.js');
3535
+ const result = validateClaimSync(rawCode);
3536
+ session.claimScopeDir = result.ok ? result.claim.scopeDir : undefined;
3537
+ }
3538
+ catch {
3539
+ // Claim store unreadable — fall through to unscoped access so
3540
+ // we don't hard-break when `.data/claims.json` is missing.
3541
+ session.claimScopeDir = undefined;
3542
+ }
3543
+ }
3544
+ else if (session.claimScopeDir) {
3545
+ // Session was previously scoped but the client stopped sending
3546
+ // the code. Treat the missing header as revocation.
3547
+ session.claimScopeDir = undefined;
3548
+ }
3549
+ }
3351
3550
  // GET - Open SSE stream for server notifications
3352
3551
  if (req.method === 'GET') {
3353
3552
  const accept = req.headers.accept || '';
@@ -3369,13 +3568,15 @@ export async function handleStreamableHTTP(req, res, options) {
3369
3568
  res.socket?.setKeepAlive(true, 60000);
3370
3569
  // Store SSE response for server-initiated messages
3371
3570
  session.sseResponse = res;
3372
- // Keep connection alive with SSE data events (every 15s for better reliability)
3571
+ // Keep connection alive with SSE comments (every 15s). Comments are
3572
+ // silently dropped by all spec-compliant parsers including the MCP
3573
+ // SDK's EventSourceParserStream, so they don't clutter JSON-RPC
3574
+ // message routing. Prevents intermediary proxies (nginx) from closing
3575
+ // idle connections.
3373
3576
  const keepAlive = setInterval(() => {
3374
- // Check if response is still writable before sending keepalive
3375
3577
  if (!res.writableEnded && !res.destroyed) {
3376
3578
  try {
3377
- // Send as data event so client onmessage handler fires and updates lastMessageTime
3378
- res.write('data: {"type":"keepalive"}\n\n');
3579
+ res.write(': keepalive\n\n');
3379
3580
  }
3380
3581
  catch (err) {
3381
3582
  // If write fails, connection is dead - clean up
@@ -3391,6 +3592,21 @@ export async function handleStreamableHTTP(req, res, options) {
3391
3592
  const cleanup = () => {
3392
3593
  clearInterval(keepAlive);
3393
3594
  session.sseResponse = undefined;
3595
+ // Reject any server→client requests still waiting on this
3596
+ // session. Without this, a disconnect during `sampling/createMessage`
3597
+ // leaves the pending entry alive until the 5-minute timeout,
3598
+ // and because Beam's sampling-modal serializes via a single
3599
+ // inFlight promise, the queue wedges for every later request
3600
+ // in that tab. Each pending entry carries the originating
3601
+ // sessionId so we can target precisely this session's work.
3602
+ for (const [id, pending] of pendingServerRequests) {
3603
+ if (pending.sessionId !== session.id)
3604
+ continue;
3605
+ if (pending.timer)
3606
+ clearTimeout(pending.timer);
3607
+ pendingServerRequests.delete(id);
3608
+ pending.reject(new Error(`requestSession: session ${session.id} disconnected before response`));
3609
+ }
3394
3610
  // Clean up subscriptions when client disconnects
3395
3611
  if (options.subscriptionManager) {
3396
3612
  options.subscriptionManager.onClientDisconnect(session.id);
@@ -3444,6 +3660,34 @@ export async function handleStreamableHTTP(req, res, options) {
3444
3660
  // Process requests
3445
3661
  const responses = [];
3446
3662
  for (const request of requests) {
3663
+ // Response to a server→client request: no method, has id, has
3664
+ // either result or error. Route to the pending-request map so
3665
+ // the samplingProvider / future server-initiated primitives see
3666
+ // the browser's reply. These never produce an outgoing response.
3667
+ //
3668
+ // SECURITY: the reply's session MUST match the session that
3669
+ // originated the server→client request. Ids are globally
3670
+ // monotonic (`srv-1`, `srv-2`, ...), so without a session cross
3671
+ // check, session A could POST a reply carrying session B's id
3672
+ // and inject a fabricated sampling result into B's photon.
3673
+ // Drop mismatched replies silently — the original timeout on
3674
+ // the pending entry stays the only way to fail legitimately.
3675
+ if (!request.method && request.id !== undefined) {
3676
+ const msg = request;
3677
+ const pending = pendingServerRequests.get(msg.id);
3678
+ if (pending && pending.sessionId === session.id) {
3679
+ if (pending.timer)
3680
+ clearTimeout(pending.timer);
3681
+ pendingServerRequests.delete(msg.id);
3682
+ if (msg.error) {
3683
+ pending.reject(new Error(msg.error.message || 'server→client request failed'));
3684
+ }
3685
+ else {
3686
+ pending.resolve(msg.result);
3687
+ }
3688
+ }
3689
+ continue;
3690
+ }
3447
3691
  const handler = handlers[request.method];
3448
3692
  if (!handler) {
3449
3693
  if (request.id !== undefined) {