mobygate 0.5.3 → 0.6.1

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,6 +53,21 @@ 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);
@@ -214,114 +229,45 @@ function collectImages(messages) {
214
229
  }
215
230
 
216
231
  // ---------------------------------------------------------------------------
217
- // Tool calling (Path B: prompt-embedded protocol)
232
+ // Tool calling (Phase 1: native MCP tools — no more <tool_call> text hack)
218
233
  // ---------------------------------------------------------------------------
219
- // The Claude Agent SDK cannot stream OpenAI-style function-call events back to
220
- // the caller (MCP handlers execute in-process and pollute session state; see
221
- // README "Known Gaps"). Workaround: inject client-provided tool schemas into
222
- // the system prompt and instruct the model to emit <tool_call>{...}</tool_call>
223
- // tags. We parse those out and re-emit as OpenAI `tool_calls`. Tool results
224
- // 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.
225
240
 
226
241
  function hasTools(body) {
227
242
  return Array.isArray(body?.tools) && body.tools.length > 0;
228
243
  }
229
244
 
230
- function buildToolInstructions(tools) {
231
- const lines = [
232
- '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":',
233
- '',
234
- '<tool_call>{"name":"<tool_name>","arguments":{<args>}}</tool_call>',
235
- '',
236
- 'Rules:',
237
- '- Do NOT wrap <tool_call> tags in markdown code fences.',
238
- '- When you emit <tool_call> tags, output ONLY the tags — no prose, no explanation, no other text.',
239
- '- You may emit multiple <tool_call> tags to request parallel calls.',
240
- '- Tool results will be returned as <tool_result id="..." name="...">...</tool_result> blocks. After results arrive, continue toward the final answer.',
241
- '- When you have the final answer and need no more tool calls, respond normally WITHOUT any <tool_call> tag.',
242
- '- Do NOT call any other tool (Read, Bash, Grep, etc.) — only the tools listed below.',
243
- '',
244
- 'Available tools:',
245
- ];
246
- for (const t of tools) {
247
- if (t?.type !== 'function' || !t.function) continue;
248
- const fn = t.function;
249
- lines.push(`<tool name="${fn.name}">`);
250
- if (fn.description) lines.push(` <description>${fn.description}</description>`);
251
- lines.push(` <parameters>${JSON.stringify(fn.parameters || { type: 'object', properties: {} })}</parameters>`);
252
- lines.push('</tool>');
253
- }
254
- return lines.join('\n');
255
- }
256
-
257
- function formatAssistantForReplay(msg) {
258
- const parts = [];
259
- const text = extractContent(msg.content);
260
- if (text) parts.push(text);
261
- if (Array.isArray(msg.tool_calls)) {
262
- for (const tc of msg.tool_calls) {
263
- if (tc?.type === 'function' && tc.function) {
264
- let args = {};
265
- try { args = JSON.parse(tc.function.arguments || '{}'); } catch {}
266
- parts.push(`<tool_call>${JSON.stringify({ name: tc.function.name, arguments: args })}</tool_call>`);
267
- }
268
- }
269
- }
270
- return parts.join('\n');
271
- }
272
-
273
- function formatToolResult(msg) {
274
- const content = extractContent(msg.content);
275
- const id = msg.tool_call_id || 'unknown';
276
- const name = msg.name || '';
277
- return `<tool_result id="${id}" name="${name}">\n${content}\n</tool_result>`;
278
- }
279
-
280
- // Parse the model's text output for <tool_call> tags. Returns
281
- // { toolCalls: [{id, name, arguments}], textBefore: string }
282
- // when at least one valid call is found, else null.
283
- function parseToolCalls(text) {
284
- if (!text || !text.includes('<tool_call>')) return null;
285
- const re = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
286
- const calls = [];
287
- let firstIdx = -1;
288
- let m;
289
- while ((m = re.exec(text)) !== null) {
290
- if (firstIdx === -1) firstIdx = m.index;
291
- try {
292
- const obj = JSON.parse(m[1]);
293
- if (obj && typeof obj.name === 'string') {
294
- calls.push({
295
- id: `call_${uuidv4().replace(/-/g, '').slice(0, 20)}`,
296
- name: obj.name,
297
- arguments: JSON.stringify(obj.arguments ?? {}),
298
- });
299
- }
300
- } catch {
301
- // ignore malformed tool_call blocks
302
- }
303
- }
304
- if (!calls.length) return null;
305
- return { toolCalls: calls, textBefore: text.slice(0, firstIdx).trim() };
306
- }
307
-
308
- // Detect whether the running text contains a COMPLETE <tool_call>...</tool_call>
309
- // pair — used to abort the SDK early once a call has been emitted.
310
- function hasCompleteToolCall(text) {
311
- return /<tool_call>\s*[\s\S]*?<\/tool_call>/.test(text);
312
- }
313
-
314
- function messagesToPrompt(messages, { resuming = false, tools = null } = {}) {
315
- // When resuming, the SDK already has full history. Only send the new tail:
316
- // tool_results (if the client is replying with tool outputs) and/or a fresh
317
- // 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 } = {}) {
318
261
  if (resuming) {
319
- 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 = [];
320
266
  let userText = '';
321
267
  for (let i = messages.length - 1; i >= 0; i--) {
322
268
  const msg = messages[i];
323
269
  if (msg.role === 'tool') {
324
- toolResults.unshift(formatToolResult(msg));
270
+ trailingToolMessages.unshift(msg);
325
271
  } else if (msg.role === 'user') {
326
272
  userText = extractContent(msg.content);
327
273
  break;
@@ -329,39 +275,20 @@ function messagesToPrompt(messages, { resuming = false, tools = null } = {}) {
329
275
  break;
330
276
  }
331
277
  }
278
+ const toolResultsText = toolMessagesToText(trailingToolMessages);
332
279
  const parts = [];
333
- if (toolResults.length) {
334
- parts.push(`<tool_results>\n${toolResults.join('\n')}\n</tool_results>`);
335
- // The model sometimes treats a bare <tool_results> block as "just data"
336
- // and returns empty. A short nudge keeps the turn productive without
337
- // biasing what comes next.
338
- 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.');
339
- }
280
+ if (toolResultsText) parts.push(toolResultsText);
340
281
  if (userText) parts.push(userText);
341
- 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
+ };
342
285
  }
343
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).
344
290
  const parts = [];
345
- // Tool instructions prepended once at the top of the system context.
346
- if (tools && tools.length) {
347
- parts.push(`<system>\n${buildToolInstructions(tools)}\n</system>\n`);
348
- }
349
-
350
- // Group consecutive tool-role messages so they emit as one <tool_results> block.
351
- let toolBuffer = [];
352
- const flushTools = () => {
353
- if (toolBuffer.length) {
354
- parts.push(`<tool_results>\n${toolBuffer.join('\n')}\n</tool_results>\n`);
355
- toolBuffer = [];
356
- }
357
- };
358
-
359
291
  for (const msg of messages) {
360
- if (msg.role === 'tool') {
361
- toolBuffer.push(formatToolResult(msg));
362
- continue;
363
- }
364
- flushTools();
365
292
  switch (msg.role) {
366
293
  case 'system':
367
294
  parts.push(`<system>\n${extractContent(msg.content)}\n</system>\n`);
@@ -369,18 +296,34 @@ function messagesToPrompt(messages, { resuming = false, tools = null } = {}) {
369
296
  case 'user':
370
297
  parts.push(extractContent(msg.content));
371
298
  break;
372
- case 'assistant':
373
- 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);
374
312
  break;
313
+ }
375
314
  }
376
315
  }
377
- flushTools();
378
- return parts.join('\n').trim();
316
+ return {
317
+ promptText: parts.join('\n').trim(),
318
+ };
379
319
  }
380
320
 
381
- // Wrap a prompt + optional image blocks into the form query() expects.
382
- // Returns a string when there are no images (fast path), or an async iterable
383
- // 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
+ */
384
327
  function buildQueryPrompt(promptText, imageBlocks) {
385
328
  if (!imageBlocks.length) return promptText;
386
329
  const content = [
@@ -443,12 +386,15 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
443
386
  const existing = getSession(sessionKey);
444
387
  const resuming = !!existing?.sdkSessionId;
445
388
  const toolsEnabled = hasTools(body);
446
- const promptText = messagesToPrompt(body.messages, { resuming, tools: toolsEnabled ? body.tools : null });
389
+ const { promptText } = messagesToPrompt(body.messages, { resuming });
447
390
  const images = collectImages(body.messages);
448
391
  const prompt = buildQueryPrompt(promptText, images);
449
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;
450
396
  if (images.length) console.log(` [multimodal] ${images.length} image block(s)`);
451
- 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`);
452
398
 
453
399
  res.setHeader('Content-Type', 'text/event-stream');
454
400
  res.setHeader('Cache-Control', 'no-cache');
@@ -473,11 +419,17 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
473
419
  console.log(` [session] resuming: ${sessionKey} → sdk=${existing.sdkSessionId} (msgs=${existing.messageCount})`);
474
420
  }
475
421
 
476
- 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()
477
428
 
478
429
  const runQuery = async () => {
479
430
  // Reset per-attempt state so a 401 retry starts clean
480
431
  bufferedText = '';
432
+ collectedToolCalls = [];
481
433
  isFirst = true;
482
434
  resolvedModel = model;
483
435
  capturedSessionId = existing?.sdkSessionId || null;
@@ -490,7 +442,18 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
490
442
  permissionMode: 'bypassPermissions',
491
443
  allowDangerouslySkipPermissions: true,
492
444
  abortController,
493
- ...(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
+ : {}),
494
457
  ...(resuming ? { resume: existing.sdkSessionId } : {}),
495
458
  ...(sessionKey && !resuming ? { persistSession: true } : {}),
496
459
  },
@@ -532,15 +495,25 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
532
495
  throw new AuthFailureInResultText(turnText);
533
496
  }
534
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
+
535
512
  if (turnText) {
536
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.
537
516
  bufferedText += turnText;
538
- // Abort early once we see a complete <tool_call>...</tool_call>
539
- if (hasCompleteToolCall(bufferedText)) {
540
- console.log(' [tools] complete tool_call detected — aborting SDK');
541
- abortController.abort();
542
- break;
543
- }
544
517
  } else {
545
518
  sendSSE(res, makeChunk(requestId, resolvedModel, turnText, isFirst ? 'assistant' : undefined, null));
546
519
  isFirst = false;
@@ -586,9 +559,8 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
586
559
  // Tools mode: emit the buffered response as a single chunk with either
587
560
  // tool_calls (+ finish_reason: tool_calls) or plain text (+ stop).
588
561
  if (toolsEnabled && !res.writableEnded) {
589
- const parsed = parseToolCalls(bufferedText);
590
- if (parsed) {
591
- 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)`);
592
564
  const chunk = {
593
565
  id: `chatcmpl-${requestId}`,
594
566
  object: 'chat.completion.chunk',
@@ -598,8 +570,8 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
598
570
  index: 0,
599
571
  delta: {
600
572
  role: 'assistant',
601
- content: parsed.textBefore || null,
602
- tool_calls: parsed.toolCalls.map((tc, i) => ({
573
+ content: bufferedText.trim() || null,
574
+ tool_calls: collectedToolCalls.map((tc, i) => ({
603
575
  index: i,
604
576
  id: tc.id,
605
577
  type: 'function',
@@ -634,14 +606,16 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
634
606
  const existing = getSession(sessionKey);
635
607
  const resuming = !!existing?.sdkSessionId;
636
608
  const toolsEnabled = hasTools(body);
637
- const promptText = messagesToPrompt(body.messages, { resuming, tools: toolsEnabled ? body.tools : null });
609
+ const { promptText } = messagesToPrompt(body.messages, { resuming });
638
610
  const images = collectImages(body.messages);
639
611
  const prompt = buildQueryPrompt(promptText, images);
640
612
  const model = resolveModel(body.model);
613
+ const clientToolsServer = toolsEnabled ? buildClientToolsServer(body.tools) : null;
641
614
  if (images.length) console.log(` [multimodal] ${images.length} image block(s)`);
642
- 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`);
643
616
 
644
617
  let resultText = '';
618
+ let collectedToolCalls = [];
645
619
  let resolvedModel = model;
646
620
  let inputTokens = 0;
647
621
  let outputTokens = 0;
@@ -655,6 +629,7 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
655
629
  const runQuery = async () => {
656
630
  // Reset per-attempt state so a 401 retry starts clean
657
631
  resultText = '';
632
+ collectedToolCalls = [];
658
633
  resolvedModel = model;
659
634
  inputTokens = 0;
660
635
  outputTokens = 0;
@@ -668,7 +643,14 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
668
643
  permissionMode: 'bypassPermissions',
669
644
  allowDangerouslySkipPermissions: true,
670
645
  abortController,
671
- ...(toolsEnabled ? { allowedTools: [] } : {}),
646
+ ...(clientToolsServer
647
+ ? {
648
+ mcpServers: { [MCP_SERVER_NAME]: clientToolsServer },
649
+ allowedTools: [`${MCP_TOOL_PREFIX}*`],
650
+ }
651
+ : toolsEnabled
652
+ ? { allowedTools: [] }
653
+ : {}),
672
654
  ...(resuming ? { resume: existing.sdkSessionId } : {}),
673
655
  ...(sessionKey && !resuming ? { persistSession: true } : {}),
674
656
  },
@@ -696,11 +678,15 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
696
678
  abortController.abort();
697
679
  throw new AuthFailureInResultText(resultText);
698
680
  }
699
- // Abort early once we see a complete <tool_call>...</tool_call>
700
- if (toolsEnabled && hasCompleteToolCall(resultText)) {
701
- console.log(' [tools] complete tool_call detected — aborting SDK');
702
- abortController.abort();
703
- 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
+ }
704
690
  }
705
691
  }
706
692
 
@@ -740,32 +726,29 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
740
726
  if (sessionKey) responseHeaders['X-Session-Id'] = sessionKey;
741
727
 
742
728
  // Tool-calling response shape
743
- if (toolsEnabled) {
744
- const parsed = parseToolCalls(resultText);
745
- if (parsed) {
746
- console.log(` [tools] emitting ${parsed.toolCalls.length} tool_call(s)`);
747
- return res.set(responseHeaders).json({
748
- id: `chatcmpl-${requestId}`,
749
- object: 'chat.completion',
750
- created: Math.floor(Date.now() / 1000),
751
- model: normalizeModelName(resolvedModel),
752
- choices: [{
753
- index: 0,
754
- message: {
755
- role: 'assistant',
756
- content: parsed.textBefore || null,
757
- tool_calls: parsed.toolCalls.map((tc) => ({
758
- id: tc.id,
759
- type: 'function',
760
- function: { name: tc.name, arguments: tc.arguments },
761
- })),
762
- },
763
- finish_reason: 'tool_calls',
764
- }],
765
- usage: { prompt_tokens: inputTokens, completion_tokens: outputTokens, total_tokens: inputTokens + outputTokens },
766
- });
767
- }
768
- // 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
769
752
  }
770
753
 
771
754
  res.set(responseHeaders).json({
@@ -1090,6 +1073,62 @@ app.get('/dashboard/logs', async (req, res) => {
1090
1073
  }
1091
1074
  });
1092
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
+
1093
1132
  // ---------------------------------------------------------------------------
1094
1133
  // Start
1095
1134
  // ---------------------------------------------------------------------------