mobygate 0.5.2 → 0.6.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.
package/server.js CHANGED
@@ -53,11 +53,30 @@ import { banner } from './lib/ascii.js';
53
53
  import { bus as dashboardBus } from './lib/dashboard-bus.js';
54
54
  import { loadSessions, saveSessions, flushSessionsNow } from './lib/session-store.js';
55
55
  import { LOGS_DIR } from './lib/config.js';
56
+ import {
57
+ buildClientToolsServer,
58
+ extractToolUses,
59
+ hasToolUse,
60
+ toolMessagesToText,
61
+ MCP_SERVER_NAME,
62
+ MCP_TOOL_PREFIX,
63
+ } from './lib/tool-bridge.js';
64
+ import {
65
+ getUpdateCheck,
66
+ applyUpdate,
67
+ readUpdateState,
68
+ readUpdateLogTail,
69
+ getCurrentVersion,
70
+ } from './lib/updater.js';
56
71
 
57
72
  const __filename = fileURLToPath(import.meta.url);
58
73
  const __dirname = dirname(__filename);
59
74
 
60
75
  const PORT = parseInt(process.env.PORT || '3456', 10);
76
+ // Bind to loopback only by default — no LAN exposure. Users who intentionally
77
+ // want to share the proxy on a network can set bind: 0.0.0.0 (or a specific
78
+ // interface) in ~/.mobygate/config.yaml, but should add auth in front of it.
79
+ const BIND = process.env.BIND || '127.0.0.1';
61
80
  const DEFAULT_MODEL = process.env.DEFAULT_MODEL || 'claude-opus-4-7[1m]';
62
81
  const SESSION_TTL_MS = parseInt(process.env.SESSION_TTL_MS || String(60 * 60 * 1000), 10); // 1 hour default
63
82
 
@@ -210,114 +229,45 @@ function collectImages(messages) {
210
229
  }
211
230
 
212
231
  // ---------------------------------------------------------------------------
213
- // Tool calling (Path B: prompt-embedded protocol)
232
+ // Tool calling (Phase 1: native MCP tools — no more <tool_call> text hack)
214
233
  // ---------------------------------------------------------------------------
215
- // The Claude Agent SDK cannot stream OpenAI-style function-call events back to
216
- // the caller (MCP handlers execute in-process and pollute session state; see
217
- // README "Known Gaps"). Workaround: inject client-provided tool schemas into
218
- // the system prompt and instruct the model to emit <tool_call>{...}</tool_call>
219
- // tags. We parse those out and re-emit as OpenAI `tool_calls`. Tool results
220
- // coming back from the client get wrapped in <tool_result> blocks.
234
+ // Client-provided OpenAI tools are registered with the SDK as in-process MCP
235
+ // tools (see lib/tool-bridge.js). The model emits **native** tool_use content
236
+ // blocks in its assistant messages; we abort the SDK on the first one and
237
+ // return OpenAI tool_calls to the client. When the client replies with tool
238
+ // results, we send them back as Anthropic tool_result content blocks inside
239
+ // a single SDKUserMessage round-tripping cleanly through the SDK session.
221
240
 
222
241
  function hasTools(body) {
223
242
  return Array.isArray(body?.tools) && body.tools.length > 0;
224
243
  }
225
244
 
226
- function buildToolInstructions(tools) {
227
- const lines = [
228
- 'You have access to CLIENT-DEFINED tools listed below. To invoke a tool, emit one or more <tool_call> tags, each containing a strict JSON object with "name" and "arguments":',
229
- '',
230
- '<tool_call>{"name":"<tool_name>","arguments":{<args>}}</tool_call>',
231
- '',
232
- 'Rules:',
233
- '- Do NOT wrap <tool_call> tags in markdown code fences.',
234
- '- When you emit <tool_call> tags, output ONLY the tags — no prose, no explanation, no other text.',
235
- '- You may emit multiple <tool_call> tags to request parallel calls.',
236
- '- Tool results will be returned as <tool_result id="..." name="...">...</tool_result> blocks. After results arrive, continue toward the final answer.',
237
- '- When you have the final answer and need no more tool calls, respond normally WITHOUT any <tool_call> tag.',
238
- '- Do NOT call any other tool (Read, Bash, Grep, etc.) — only the tools listed below.',
239
- '',
240
- 'Available tools:',
241
- ];
242
- for (const t of tools) {
243
- if (t?.type !== 'function' || !t.function) continue;
244
- const fn = t.function;
245
- lines.push(`<tool name="${fn.name}">`);
246
- if (fn.description) lines.push(` <description>${fn.description}</description>`);
247
- lines.push(` <parameters>${JSON.stringify(fn.parameters || { type: 'object', properties: {} })}</parameters>`);
248
- lines.push('</tool>');
249
- }
250
- return lines.join('\n');
251
- }
252
-
253
- function formatAssistantForReplay(msg) {
254
- const parts = [];
255
- const text = extractContent(msg.content);
256
- if (text) parts.push(text);
257
- if (Array.isArray(msg.tool_calls)) {
258
- for (const tc of msg.tool_calls) {
259
- if (tc?.type === 'function' && tc.function) {
260
- let args = {};
261
- try { args = JSON.parse(tc.function.arguments || '{}'); } catch {}
262
- parts.push(`<tool_call>${JSON.stringify({ name: tc.function.name, arguments: args })}</tool_call>`);
263
- }
264
- }
265
- }
266
- return parts.join('\n');
267
- }
268
-
269
- function formatToolResult(msg) {
270
- const content = extractContent(msg.content);
271
- const id = msg.tool_call_id || 'unknown';
272
- const name = msg.name || '';
273
- return `<tool_result id="${id}" name="${name}">\n${content}\n</tool_result>`;
274
- }
275
-
276
- // Parse the model's text output for <tool_call> tags. Returns
277
- // { toolCalls: [{id, name, arguments}], textBefore: string }
278
- // when at least one valid call is found, else null.
279
- function parseToolCalls(text) {
280
- if (!text || !text.includes('<tool_call>')) return null;
281
- const re = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
282
- const calls = [];
283
- let firstIdx = -1;
284
- let m;
285
- while ((m = re.exec(text)) !== null) {
286
- if (firstIdx === -1) firstIdx = m.index;
287
- try {
288
- const obj = JSON.parse(m[1]);
289
- if (obj && typeof obj.name === 'string') {
290
- calls.push({
291
- id: `call_${uuidv4().replace(/-/g, '').slice(0, 20)}`,
292
- name: obj.name,
293
- arguments: JSON.stringify(obj.arguments ?? {}),
294
- });
295
- }
296
- } catch {
297
- // ignore malformed tool_call blocks
298
- }
299
- }
300
- if (!calls.length) return null;
301
- return { toolCalls: calls, textBefore: text.slice(0, firstIdx).trim() };
302
- }
303
-
304
- // Detect whether the running text contains a COMPLETE <tool_call>...</tool_call>
305
- // pair — used to abort the SDK early once a call has been emitted.
306
- function hasCompleteToolCall(text) {
307
- return /<tool_call>\s*[\s\S]*?<\/tool_call>/.test(text);
308
- }
309
-
310
- function messagesToPrompt(messages, { resuming = false, tools = null } = {}) {
311
- // When resuming, the SDK already has full history. Only send the new tail:
312
- // tool_results (if the client is replying with tool outputs) and/or a fresh
313
- // user message.
245
+ /**
246
+ * Build the prompt text from the OpenAI messages array.
247
+ *
248
+ * Returns `{ promptText }` — a single string ready for the SDK. Tool
249
+ * results are spliced in as <tool_results> XML when present (see
250
+ * lib/tool-bridge.js#toolMessagesToText for why we don't use native
251
+ * tool_result content blocks yet).
252
+ *
253
+ * Resuming vs fresh:
254
+ * - Resuming: SDK has full history. We only send the new tail —
255
+ * trailing tool results plus the most recent user text, if any.
256
+ * - Fresh: SDK starts cold. We serialize the visible history with
257
+ * <system>/<previous_response>/<tool_results> tags. No tool-
258
+ * instruction injection — the SDK MCP registration handles that.
259
+ */
260
+ function messagesToPrompt(messages, { resuming = false } = {}) {
314
261
  if (resuming) {
315
- const toolResults = [];
262
+ // Walk backwards from the end, collecting trailing tool messages and
263
+ // the most recent user text. Tool results are formatted as a text
264
+ // block (see lib/tool-bridge.js#toolMessagesToText for the rationale).
265
+ const trailingToolMessages = [];
316
266
  let userText = '';
317
267
  for (let i = messages.length - 1; i >= 0; i--) {
318
268
  const msg = messages[i];
319
269
  if (msg.role === 'tool') {
320
- toolResults.unshift(formatToolResult(msg));
270
+ trailingToolMessages.unshift(msg);
321
271
  } else if (msg.role === 'user') {
322
272
  userText = extractContent(msg.content);
323
273
  break;
@@ -325,39 +275,20 @@ function messagesToPrompt(messages, { resuming = false, tools = null } = {}) {
325
275
  break;
326
276
  }
327
277
  }
278
+ const toolResultsText = toolMessagesToText(trailingToolMessages);
328
279
  const parts = [];
329
- if (toolResults.length) {
330
- parts.push(`<tool_results>\n${toolResults.join('\n')}\n</tool_results>`);
331
- // The model sometimes treats a bare <tool_results> block as "just data"
332
- // and returns empty. A short nudge keeps the turn productive without
333
- // biasing what comes next.
334
- if (!userText) parts.push('Use the tool results above to continue toward the final answer. If more tool calls are needed, emit them; otherwise respond directly.');
335
- }
280
+ if (toolResultsText) parts.push(toolResultsText);
336
281
  if (userText) parts.push(userText);
337
- return parts.join('\n\n') || extractContent(messages[messages.length - 1].content);
282
+ return {
283
+ promptText: parts.join('\n\n') || extractContent(messages[messages.length - 1]?.content || ''),
284
+ };
338
285
  }
339
286
 
287
+ // Fresh request: serialize visible history as XML-wrapped text. No
288
+ // tool-instruction injection (the model learns about tools via the SDK
289
+ // MCP registration, not the prompt).
340
290
  const parts = [];
341
- // Tool instructions prepended once at the top of the system context.
342
- if (tools && tools.length) {
343
- parts.push(`<system>\n${buildToolInstructions(tools)}\n</system>\n`);
344
- }
345
-
346
- // Group consecutive tool-role messages so they emit as one <tool_results> block.
347
- let toolBuffer = [];
348
- const flushTools = () => {
349
- if (toolBuffer.length) {
350
- parts.push(`<tool_results>\n${toolBuffer.join('\n')}\n</tool_results>\n`);
351
- toolBuffer = [];
352
- }
353
- };
354
-
355
291
  for (const msg of messages) {
356
- if (msg.role === 'tool') {
357
- toolBuffer.push(formatToolResult(msg));
358
- continue;
359
- }
360
- flushTools();
361
292
  switch (msg.role) {
362
293
  case 'system':
363
294
  parts.push(`<system>\n${extractContent(msg.content)}\n</system>\n`);
@@ -365,18 +296,34 @@ function messagesToPrompt(messages, { resuming = false, tools = null } = {}) {
365
296
  case 'user':
366
297
  parts.push(extractContent(msg.content));
367
298
  break;
368
- case 'assistant':
369
- parts.push(`<previous_response>\n${formatAssistantForReplay(msg)}\n</previous_response>\n`);
299
+ case 'assistant': {
300
+ // Best-effort replay. tool_calls in non-resume history are dropped;
301
+ // the model can usually infer continuity from the surrounding text.
302
+ const text = extractContent(msg.content);
303
+ if (text) parts.push(`<previous_response>\n${text}\n</previous_response>\n`);
304
+ break;
305
+ }
306
+ case 'tool': {
307
+ // Tool messages on a fresh turn (rare — clients normally use
308
+ // session keys). Splice as text since there's no preceding
309
+ // tool_use turn we can bind to natively.
310
+ const text = toolMessagesToText([msg]);
311
+ if (text) parts.push(text);
370
312
  break;
313
+ }
371
314
  }
372
315
  }
373
- flushTools();
374
- return parts.join('\n').trim();
316
+ return {
317
+ promptText: parts.join('\n').trim(),
318
+ };
375
319
  }
376
320
 
377
- // Wrap a prompt + optional image blocks into the form query() expects.
378
- // Returns a string when there are no images (fast path), or an async iterable
379
- // yielding one SDKUserMessage with multi-part content when there are.
321
+ /**
322
+ * Wrap promptText + optional image blocks into the form query() expects.
323
+ * Returns a string for the fast path (text-only, no images), or an
324
+ * async iterable yielding one SDKUserMessage with multi-part content
325
+ * when there are images.
326
+ */
380
327
  function buildQueryPrompt(promptText, imageBlocks) {
381
328
  if (!imageBlocks.length) return promptText;
382
329
  const content = [
@@ -439,12 +386,15 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
439
386
  const existing = getSession(sessionKey);
440
387
  const resuming = !!existing?.sdkSessionId;
441
388
  const toolsEnabled = hasTools(body);
442
- const promptText = messagesToPrompt(body.messages, { resuming, tools: toolsEnabled ? body.tools : null });
389
+ const { promptText } = messagesToPrompt(body.messages, { resuming });
443
390
  const images = collectImages(body.messages);
444
391
  const prompt = buildQueryPrompt(promptText, images);
445
392
  const model = resolveModel(body.model);
393
+ // Build the in-process MCP server exposing client tools to the SDK.
394
+ // null when toolsEnabled is false (or all tools are malformed).
395
+ const clientToolsServer = toolsEnabled ? buildClientToolsServer(body.tools) : null;
446
396
  if (images.length) console.log(` [multimodal] ${images.length} image block(s)`);
447
- if (toolsEnabled) console.log(` [tools] ${body.tools.length} client tool(s) buffering stream`);
397
+ if (toolsEnabled) console.log(` [tools] ${body.tools.length} client tool(s) registered as MCP`);
448
398
 
449
399
  res.setHeader('Content-Type', 'text/event-stream');
450
400
  res.setHeader('Cache-Control', 'no-cache');
@@ -469,11 +419,17 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
469
419
  console.log(` [session] resuming: ${sessionKey} → sdk=${existing.sdkSessionId} (msgs=${existing.messageCount})`);
470
420
  }
471
421
 
472
- let bufferedText = ''; // only used when toolsEnabled
422
+ // Tools-mode buffers text and collects native tool_use blocks. If the
423
+ // model emits text first then a tool_use, we want both: textBefore as
424
+ // the assistant content, plus the tool_calls. (Most clients display the
425
+ // text and then act on the tool_calls.)
426
+ let bufferedText = '';
427
+ let collectedToolCalls = []; // [{id, name, arguments}] from extractToolUses()
473
428
 
474
429
  const runQuery = async () => {
475
430
  // Reset per-attempt state so a 401 retry starts clean
476
431
  bufferedText = '';
432
+ collectedToolCalls = [];
477
433
  isFirst = true;
478
434
  resolvedModel = model;
479
435
  capturedSessionId = existing?.sdkSessionId || null;
@@ -486,7 +442,18 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
486
442
  permissionMode: 'bypassPermissions',
487
443
  allowDangerouslySkipPermissions: true,
488
444
  abortController,
489
- ...(toolsEnabled ? { allowedTools: [] } : {}),
445
+ // Tools-mode: register client tools as an in-process MCP server
446
+ // and allow only those (no Bash/Read/etc. — the SDK's built-ins
447
+ // would pollute the session and leak through to the model).
448
+ ...(clientToolsServer
449
+ ? {
450
+ mcpServers: { [MCP_SERVER_NAME]: clientToolsServer },
451
+ allowedTools: [`${MCP_TOOL_PREFIX}*`],
452
+ }
453
+ : toolsEnabled
454
+ // Tools were requested but none were valid — disable all tools.
455
+ ? { allowedTools: [] }
456
+ : {}),
490
457
  ...(resuming ? { resume: existing.sdkSessionId } : {}),
491
458
  ...(sessionKey && !resuming ? { persistSession: true } : {}),
492
459
  },
@@ -528,15 +495,25 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
528
495
  throw new AuthFailureInResultText(turnText);
529
496
  }
530
497
 
498
+ // Tools-mode: check for native tool_use content blocks. The moment
499
+ // we see one, abort the SDK — we don't want our stub handler to
500
+ // hang waiting on an execution that's actually happening client-side.
501
+ if (toolsEnabled && message.type === 'assistant' && hasToolUse(message)) {
502
+ const calls = extractToolUses(message);
503
+ if (calls.length) {
504
+ collectedToolCalls.push(...calls);
505
+ if (turnText) bufferedText += turnText;
506
+ console.log(` [tools] ${calls.length} native tool_use block(s) — aborting SDK`);
507
+ abortController.abort();
508
+ break;
509
+ }
510
+ }
511
+
531
512
  if (turnText) {
532
513
  if (toolsEnabled) {
514
+ // Buffer text in case it precedes a tool_use, or ends up as the
515
+ // final response when the model decides not to call any tools.
533
516
  bufferedText += turnText;
534
- // Abort early once we see a complete <tool_call>...</tool_call>
535
- if (hasCompleteToolCall(bufferedText)) {
536
- console.log(' [tools] complete tool_call detected — aborting SDK');
537
- abortController.abort();
538
- break;
539
- }
540
517
  } else {
541
518
  sendSSE(res, makeChunk(requestId, resolvedModel, turnText, isFirst ? 'assistant' : undefined, null));
542
519
  isFirst = false;
@@ -582,9 +559,8 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
582
559
  // Tools mode: emit the buffered response as a single chunk with either
583
560
  // tool_calls (+ finish_reason: tool_calls) or plain text (+ stop).
584
561
  if (toolsEnabled && !res.writableEnded) {
585
- const parsed = parseToolCalls(bufferedText);
586
- if (parsed) {
587
- console.log(` [tools] emitting ${parsed.toolCalls.length} tool_call(s)`);
562
+ if (collectedToolCalls.length > 0) {
563
+ console.log(` [tools] emitting ${collectedToolCalls.length} tool_call(s)`);
588
564
  const chunk = {
589
565
  id: `chatcmpl-${requestId}`,
590
566
  object: 'chat.completion.chunk',
@@ -594,8 +570,8 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
594
570
  index: 0,
595
571
  delta: {
596
572
  role: 'assistant',
597
- content: parsed.textBefore || null,
598
- tool_calls: parsed.toolCalls.map((tc, i) => ({
573
+ content: bufferedText.trim() || null,
574
+ tool_calls: collectedToolCalls.map((tc, i) => ({
599
575
  index: i,
600
576
  id: tc.id,
601
577
  type: 'function',
@@ -630,14 +606,16 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
630
606
  const existing = getSession(sessionKey);
631
607
  const resuming = !!existing?.sdkSessionId;
632
608
  const toolsEnabled = hasTools(body);
633
- const promptText = messagesToPrompt(body.messages, { resuming, tools: toolsEnabled ? body.tools : null });
609
+ const { promptText } = messagesToPrompt(body.messages, { resuming });
634
610
  const images = collectImages(body.messages);
635
611
  const prompt = buildQueryPrompt(promptText, images);
636
612
  const model = resolveModel(body.model);
613
+ const clientToolsServer = toolsEnabled ? buildClientToolsServer(body.tools) : null;
637
614
  if (images.length) console.log(` [multimodal] ${images.length} image block(s)`);
638
- if (toolsEnabled) console.log(` [tools] ${body.tools.length} client tool(s)`);
615
+ if (toolsEnabled) console.log(` [tools] ${body.tools.length} client tool(s) registered as MCP`);
639
616
 
640
617
  let resultText = '';
618
+ let collectedToolCalls = [];
641
619
  let resolvedModel = model;
642
620
  let inputTokens = 0;
643
621
  let outputTokens = 0;
@@ -651,6 +629,7 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
651
629
  const runQuery = async () => {
652
630
  // Reset per-attempt state so a 401 retry starts clean
653
631
  resultText = '';
632
+ collectedToolCalls = [];
654
633
  resolvedModel = model;
655
634
  inputTokens = 0;
656
635
  outputTokens = 0;
@@ -664,7 +643,14 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
664
643
  permissionMode: 'bypassPermissions',
665
644
  allowDangerouslySkipPermissions: true,
666
645
  abortController,
667
- ...(toolsEnabled ? { allowedTools: [] } : {}),
646
+ ...(clientToolsServer
647
+ ? {
648
+ mcpServers: { [MCP_SERVER_NAME]: clientToolsServer },
649
+ allowedTools: [`${MCP_TOOL_PREFIX}*`],
650
+ }
651
+ : toolsEnabled
652
+ ? { allowedTools: [] }
653
+ : {}),
668
654
  ...(resuming ? { resume: existing.sdkSessionId } : {}),
669
655
  ...(sessionKey && !resuming ? { persistSession: true } : {}),
670
656
  },
@@ -692,11 +678,15 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
692
678
  abortController.abort();
693
679
  throw new AuthFailureInResultText(resultText);
694
680
  }
695
- // Abort early once we see a complete <tool_call>...</tool_call>
696
- if (toolsEnabled && hasCompleteToolCall(resultText)) {
697
- console.log(' [tools] complete tool_call detected — aborting SDK');
698
- abortController.abort();
699
- break;
681
+ // Native tool_use detection abort the moment a tool_use lands.
682
+ if (toolsEnabled && hasToolUse(message)) {
683
+ const calls = extractToolUses(message);
684
+ if (calls.length) {
685
+ collectedToolCalls.push(...calls);
686
+ console.log(` [tools] ${calls.length} native tool_use block(s) — aborting SDK`);
687
+ abortController.abort();
688
+ break;
689
+ }
700
690
  }
701
691
  }
702
692
 
@@ -736,32 +726,29 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
736
726
  if (sessionKey) responseHeaders['X-Session-Id'] = sessionKey;
737
727
 
738
728
  // Tool-calling response shape
739
- if (toolsEnabled) {
740
- const parsed = parseToolCalls(resultText);
741
- if (parsed) {
742
- console.log(` [tools] emitting ${parsed.toolCalls.length} tool_call(s)`);
743
- return res.set(responseHeaders).json({
744
- id: `chatcmpl-${requestId}`,
745
- object: 'chat.completion',
746
- created: Math.floor(Date.now() / 1000),
747
- model: normalizeModelName(resolvedModel),
748
- choices: [{
749
- index: 0,
750
- message: {
751
- role: 'assistant',
752
- content: parsed.textBefore || null,
753
- tool_calls: parsed.toolCalls.map((tc) => ({
754
- id: tc.id,
755
- type: 'function',
756
- function: { name: tc.name, arguments: tc.arguments },
757
- })),
758
- },
759
- finish_reason: 'tool_calls',
760
- }],
761
- usage: { prompt_tokens: inputTokens, completion_tokens: outputTokens, total_tokens: inputTokens + outputTokens },
762
- });
763
- }
764
- // No tool_call tags → fall through to normal text response
729
+ if (toolsEnabled && collectedToolCalls.length > 0) {
730
+ console.log(` [tools] emitting ${collectedToolCalls.length} tool_call(s)`);
731
+ return res.set(responseHeaders).json({
732
+ id: `chatcmpl-${requestId}`,
733
+ object: 'chat.completion',
734
+ created: Math.floor(Date.now() / 1000),
735
+ model: normalizeModelName(resolvedModel),
736
+ choices: [{
737
+ index: 0,
738
+ message: {
739
+ role: 'assistant',
740
+ content: resultText.trim() || null,
741
+ tool_calls: collectedToolCalls.map((tc) => ({
742
+ id: tc.id,
743
+ type: 'function',
744
+ function: { name: tc.name, arguments: tc.arguments },
745
+ })),
746
+ },
747
+ finish_reason: 'tool_calls',
748
+ }],
749
+ usage: { prompt_tokens: inputTokens, completion_tokens: outputTokens, total_tokens: inputTokens + outputTokens },
750
+ });
751
+ // No tool_use blocks fall through to normal text response
765
752
  }
766
753
 
767
754
  res.set(responseHeaders).json({
@@ -1086,18 +1073,74 @@ app.get('/dashboard/logs', async (req, res) => {
1086
1073
  }
1087
1074
  });
1088
1075
 
1076
+ // ---------------------------------------------------------------------------
1077
+ // Updater — dashboard-driven "update available → update now" flow
1078
+ // ---------------------------------------------------------------------------
1079
+
1080
+ // GET /update/check — is there a newer mobygate on npm?
1081
+ // Response: { current, latest, updateAvailable, installMode, canApply, cached, error }
1082
+ // Safe to poll: the npm registry call is cached for 15 min in-process.
1083
+ app.get('/update/check', async (req, res) => {
1084
+ try {
1085
+ const force = req.query.force === '1' || req.query.force === 'true';
1086
+ const info = await getUpdateCheck({ force });
1087
+ res.json(info);
1088
+ } catch (e) {
1089
+ res.status(500).json({ error: e.message });
1090
+ }
1091
+ });
1092
+
1093
+ // POST /update/apply — fire the update in a detached child process.
1094
+ // We return immediately with { started, pid }. The child runs
1095
+ // `npm install -g mobygate@latest` (or `git pull && npm install`), then
1096
+ // restarts the service — which kills us. The dashboard polls
1097
+ // /update/status to show progress and reconnects once the new server is up.
1098
+ app.post('/update/apply', (_req, res) => {
1099
+ try {
1100
+ const result = applyUpdate({});
1101
+ const status = result.started ? 202 : 409;
1102
+ res.status(status).json({ ...result, currentVersion: getCurrentVersion() });
1103
+ if (result.started) {
1104
+ dashboardBus.emitEvent({ type: 'update.started', pid: result.pid, mode: result.mode });
1105
+ }
1106
+ } catch (e) {
1107
+ res.status(500).json({ started: false, error: e.message });
1108
+ }
1109
+ });
1110
+
1111
+ // GET /update/status — progress for a running (or just-finished) update.
1112
+ // The dashboard polls this during apply. `running` is determined by
1113
+ // PID liveness, so even if our process is the one getting restarted,
1114
+ // the new one answers correctly.
1115
+ app.get('/update/status', (req, res) => {
1116
+ const state = readUpdateState();
1117
+ let running = false;
1118
+ if (state.pid) {
1119
+ try { process.kill(state.pid, 0); running = true; } catch {}
1120
+ }
1121
+ const lines = Math.min(1000, parseInt(req.query.lines || '200', 10));
1122
+ res.json({
1123
+ running,
1124
+ pid: state.pid || null,
1125
+ startedAt: state.startedAt || null,
1126
+ mode: state.mode || null,
1127
+ lines: readUpdateLogTail({ lines }),
1128
+ currentVersion: getCurrentVersion(),
1129
+ });
1130
+ });
1131
+
1089
1132
  // ---------------------------------------------------------------------------
1090
1133
  // Start
1091
1134
  // ---------------------------------------------------------------------------
1092
1135
 
1093
- app.listen(PORT, async () => {
1136
+ app.listen(PORT, BIND, async () => {
1094
1137
  const ttlMin = Math.round(SESSION_TTL_MS / 60000);
1095
1138
  const meta = await loadBuildMeta();
1096
1139
  console.log(banner({ version: meta.version }));
1097
- console.log(` port ${PORT}`);
1140
+ console.log(` bind ${BIND}:${PORT}${BIND === '127.0.0.1' ? ' (loopback only)' : ' (⚠ network-reachable — add auth)'}`);
1098
1141
  console.log(` model ${DEFAULT_MODEL}`);
1099
1142
  console.log(` session TTL ${ttlMin} min`);
1100
1143
  console.log(` dashboard http://localhost:${PORT}`);
1101
1144
  console.log('');
1102
- dashboardBus.emitEvent({ type: 'server.boot', port: PORT, defaultModel: DEFAULT_MODEL });
1145
+ dashboardBus.emitEvent({ type: 'server.boot', port: PORT, bind: BIND, defaultModel: DEFAULT_MODEL });
1103
1146
  });