narraeon 0.6.1 → 0.6.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.
@@ -68,6 +68,11 @@ const requiredFields = {
68
68
  continuation: "object",
69
69
  },
70
70
  "setting-improvement.cancel": { sessionId: "string" },
71
+ "setting-improvement.resume": {
72
+ packageId: "string",
73
+ sessionId: "string",
74
+ requestId: "string",
75
+ },
71
76
  "play.create": { name: "string" },
72
77
  "play.copy": { presetId: "string" },
73
78
  "play.save": { presetId: "string", name: "string", files: "object" },
@@ -120,6 +125,11 @@ const requiredFields = {
120
125
  continuation: "object",
121
126
  },
122
127
  "world.revision.cancel": { sessionId: "string" },
128
+ "world.revision.resume": {
129
+ worldId: "string",
130
+ sessionId: "string",
131
+ requestId: "string",
132
+ },
123
133
  "world.revision.files.replace": {
124
134
  worldId: "string",
125
135
  epochId: "string",
@@ -422,6 +432,7 @@ const requestTypes = new Set([
422
432
  "setting-improvement.rollback",
423
433
  "setting-improvement.message",
424
434
  "setting-improvement.cancel",
435
+ "setting-improvement.resume",
425
436
  "play.read",
426
437
  "play.create",
427
438
  "play.copy",
@@ -456,6 +467,7 @@ const requestTypes = new Set([
456
467
  "world.revision.session.delete",
457
468
  "world.revision.message",
458
469
  "world.revision.cancel",
470
+ "world.revision.resume",
459
471
  "world.revision.files.replace",
460
472
  "world.revision.rollback",
461
473
  "world.revision.apply",
@@ -383,6 +383,8 @@ export class V1Runtime {
383
383
  return this.#settingImprovements.send(request);
384
384
  case "setting-improvement.cancel":
385
385
  return this.#settingImprovements.cancel(request.sessionId);
386
+ case "setting-improvement.resume":
387
+ return this.#settingImprovements.resume(request);
386
388
  case "play.read":
387
389
  return this.#playPresets.list();
388
390
  case "play.create":
@@ -614,6 +616,8 @@ export class V1Runtime {
614
616
  return this.#worldRevisionCall(() => this.#worldRevisions.send(request));
615
617
  case "world.revision.cancel":
616
618
  return this.#worldRevisionCall(() => this.#worldRevisions.cancel(request.sessionId));
619
+ case "world.revision.resume":
620
+ return this.#worldRevisionCall(() => this.#worldRevisions.resume(request));
617
621
  case "world.revision.files.replace":
618
622
  return this.#worldRevisionCall(() => this.#worldRevisions.replaceFiles(request));
619
623
  case "world.revision.rollback":
@@ -119,8 +119,15 @@ export class ModelToolConversation {
119
119
  await input.settleToolResponse(session, session.modelItems.length - 1, toolCalls);
120
120
  input.changed?.(true);
121
121
  }
122
- throw new Error(input.failure?.exchangeLimit ??
123
- `Authoring exceeded ${maximumExchangesPerMessage} model exchanges for one user message`);
122
+ // The exchange budget only bounds a runaway loop. Every tool response is
123
+ // already settled, so the conversation pauses and can resume unchanged.
124
+ session.runStatus = "paused";
125
+ session.activeRequestId = null;
126
+ session.completedRequestIds = rememberRequest(session.completedRequestIds, live.requestId);
127
+ session.lastFailure = null;
128
+ session.updatedAt = Date.now();
129
+ await input.save(session);
130
+ return input.view(session);
124
131
  }
125
132
  catch (error) {
126
133
  session.runStatus = "interrupted";
@@ -154,6 +161,22 @@ export function appendAuthoringUserMessage(session, message, requestId) {
154
161
  session.lastFailure = null;
155
162
  session.updatedAt = now;
156
163
  }
164
+ /**
165
+ * Starts another exchange budget for a paused conversation without appending a
166
+ * user message; the next request replays the settled transcript as-is. The run
167
+ * still belongs to the paused user request, so that request stays active; the
168
+ * caller's resume request ID only deduplicates the resume itself.
169
+ */
170
+ export function resumePausedAuthoringConversation(session) {
171
+ if (session.runStatus !== "paused")
172
+ throw new Error("Only a paused authoring conversation can be continued");
173
+ if (session.modelItems.at(-1)?.kind !== "tool")
174
+ throw new Error("The paused authoring conversation does not end with settled tool results");
175
+ session.runStatus = "running";
176
+ session.activeRequestId = session.requests?.at(-1)?.requestId ?? null;
177
+ session.lastFailure = null;
178
+ session.updatedAt = Date.now();
179
+ }
157
180
  export function requiredAuthoringMessage(message) {
158
181
  if (message.trim().length === 0)
159
182
  throw new Error("An authoring message cannot be empty");
@@ -236,16 +236,48 @@ export function renderDocumentWritePosition(shortRef, fact, locale) {
236
236
  ? `最近写入位置 @${shortRef}:\n正文:${worldWritePositionText(fact.lastBodyWrite, locale)}\n元信息:${worldWritePositionText(fact.lastMetadataWrite, locale)}`
237
237
  : `Last committed write @${shortRef}:\nBody: ${worldWritePositionText(fact.lastBodyWrite, locale)}\nMetadata: ${worldWritePositionText(fact.lastMetadataWrite, locale)}`;
238
238
  }
239
- export function renderWorldMaintenanceReport(report, locale) {
239
+ /**
240
+ * Lists only documents the current tool response changed or that exceed their
241
+ * advisory limit, so the report stays proportional to the edit rather than to
242
+ * the package size.
243
+ */
244
+ export function renderWorldMaintenanceReport(report, changedShortRefs, locale) {
240
245
  if (report === undefined)
241
246
  return "";
242
247
  const zh = locale === "zh-CN";
248
+ const heading = zh
249
+ ? "# 文档整理报告(UTF-8 字节;超限仅提示)"
250
+ : "# Document maintenance (UTF-8 bytes; advisory only)";
251
+ const documents = report.documents.filter((document) => document.overAdvisory || changedShortRefs.has(document.shortRef));
252
+ const slots = report.slots.filter((slot) => slot.overAdvisory ||
253
+ slot.documents.some((shortRef) => changedShortRefs.has(shortRef)));
254
+ if (documents.length === 0 && slots.length === 0)
255
+ return `${heading}\n${zh ? "本次没有改动世界文档,也没有文档或材料槽超过建议上限。" : "No world document changed in this response, and no document or slot exceeds its advisory limit."}`;
256
+ const allColumns = [
257
+ { label: zh ? "正文" : "body", value: (d) => d.bodyBytes },
258
+ {
259
+ label: zh ? "注入贡献" : "injection contribution",
260
+ value: (d) => d.injectedBytes,
261
+ },
262
+ { label: zh ? "建议上限" : "advisory", value: (d) => d.advisoryBytes },
263
+ { label: zh ? "基准" : "baseline", value: (d) => d.baselineBytes },
264
+ { label: zh ? "增长" : "growth", value: (d) => d.growthBytes },
265
+ ];
266
+ // Columns that are unset for every listed document carry no information.
267
+ const columns = allColumns.filter(({ value }) => documents.some((document) => value(document) !== null));
268
+ const overAdvisory = report.documents.some(({ overAdvisory }) => overAdvisory) ||
269
+ report.slots.some(({ overAdvisory }) => overAdvisory);
243
270
  return [
244
- zh
245
- ? "# 文档整理报告(UTF-8 字节;超限仅提示)"
246
- : "# Document maintenance (UTF-8 bytes; advisory only)",
247
- `${zh ? "世界材料 / 其中检查点历史 / 作者提示 / Runtime 提示 / 工具定义" : "World materials / checkpoint history subset / author instructions / Runtime instructions / tool definitions"}: ${report.worldMaterialsBytes} / ${report.checkpointHistoryBytes} / ${report.authorInstructionBytes} / ${report.runtimeInstructionBytes} / ${report.toolDefinitionBytes}`,
248
- ...report.documents.map((document) => `@${document.shortRef}: ${zh ? "正文 / 注入贡献 / 建议上限 / 基准 / 增长" : "body / injection contribution / advisory / baseline / growth"}: ${document.bodyBytes} / ${document.injectedBytes} / ${document.advisoryBytes ?? "—"} / ${document.baselineBytes ?? "—"} / ${document.growthBytes ?? "—"}${document.overAdvisory ? (zh ? "(超过建议)" : " (over advisory)") : ""}`),
249
- ...report.slots.map((slot) => `${zh ? "材料槽" : "Slot"} ${slot.id}: ${slot.bytes} / ${slot.advisoryBytes ?? "—"}${slot.overAdvisory ? (zh ? "(超过建议)" : " (over advisory)") : ""}`),
271
+ heading,
272
+ ...(overAdvisory
273
+ ? [
274
+ `${zh ? "世界材料 / 其中检查点历史 / 作者提示 / Runtime 提示 / 工具定义" : "World materials / checkpoint history subset / author instructions / Runtime instructions / tool definitions"}: ${report.worldMaterialsBytes} / ${report.checkpointHistoryBytes} / ${report.authorInstructionBytes} / ${report.runtimeInstructionBytes} / ${report.toolDefinitionBytes}`,
275
+ ]
276
+ : []),
277
+ ...(documents.length > 0
278
+ ? [columns.map(({ label }) => label).join(" / ")]
279
+ : []),
280
+ ...documents.map((document) => `@${document.shortRef}: ${columns.map(({ value }) => value(document) ?? "—").join(" / ")}${document.overAdvisory ? (zh ? "(超过建议)" : " (over advisory)") : ""}`),
281
+ ...slots.map((slot) => `${zh ? "材料槽" : "Slot"} ${slot.id}: ${slot.bytes} / ${slot.advisoryBytes ?? "—"}${slot.overAdvisory ? (zh ? "(超过建议)" : " (over advisory)") : ""}`),
250
282
  ].join("\n");
251
283
  }
@@ -487,7 +487,10 @@ function validOptionalTitle(value) {
487
487
  !/[\r\n]/u.test(value)));
488
488
  }
489
489
  function validRunStatus(value) {
490
- return value === "ready" || value === "running" || value === "interrupted";
490
+ return (value === "ready" ||
491
+ value === "running" ||
492
+ value === "paused" ||
493
+ value === "interrupted");
491
494
  }
492
495
  function validCount(value) {
493
496
  return Number.isSafeInteger(value) && Number(value) >= 0;
@@ -329,6 +329,7 @@ export class SettingAuthoringTransaction {
329
329
  execute(calls) {
330
330
  const normalized = calls.map(normalizeCall);
331
331
  const results = [];
332
+ const responseBase = this.files();
332
333
  let lastSuccessfulWrite = -1;
333
334
  for (const call of normalized) {
334
335
  const before = this.files();
@@ -344,7 +345,9 @@ export class SettingAuthoringTransaction {
344
345
  if (lastSuccessfulWrite >= 0) {
345
346
  this.#review = this.#inspect();
346
347
  const settled = results[lastSuccessfulWrite];
347
- settled.markdown = `${settled.markdown}\n\n${renderAutomaticReview(this.#review, this.#locale)}`;
348
+ // The stored review stays cumulative for the whole user request; the
349
+ // model only needs what this tool response changed.
350
+ settled.markdown = `${settled.markdown}\n\n${renderAutomaticReview(this.#review, fileDiff(responseBase, this.#snapshot.files), this.#snapshot, this.#locale)}`;
348
351
  }
349
352
  return results;
350
353
  }
@@ -450,9 +453,16 @@ export class SettingAuthoringTransaction {
450
453
  }
451
454
  }
452
455
  }
453
- function renderAutomaticReview(review, locale) {
454
- if (review.status === "usable")
455
- return `${localized(locale, `# Current-tree review passed\n\nContent-tree validation and the real Prompt Preview both passed. This tool response changes ${review.diff.length} file(s) directly in the content package. Use this review and the coverage below to decide whether the current user request is satisfied or further edits are needed.`, `# 当前树自动检查通过\n\n内容树校验和真实 Prompt Preview 均已通过;本次工具响应直接修改内容包中的 ${review.diff.length} 个文件。请根据本次检查与下方覆盖报告判断当前用户要求是否已经满足,或是否还需修改。`)}\n\n${renderPlayCoverage(review.playCoverage, locale)}\n\n${renderWorldMaintenanceReport(review.preview?.compilation.maintenance, locale)}`;
456
+ function renderAutomaticReview(review, responseDiff, snapshot, locale) {
457
+ if (review.status === "usable" && review.preview !== null) {
458
+ const changed = responseDiff.length;
459
+ const cumulative = review.diff.length;
460
+ const changedPaths = new Set(responseDiff.map(({ path }) => path));
461
+ const changedShortRefs = new Set(listQueryableWorldDocuments(snapshot)
462
+ .filter(({ logicalPath }) => changedPaths.has(logicalPath))
463
+ .map(({ shortRef }) => shortRef));
464
+ return `${localized(locale, `# Current-tree review passed\n\nContent-tree validation and the real Prompt Preview both passed. This tool response changed ${changed} file(s) in the content package${cumulative === changed ? "" : `; ${cumulative} file(s) changed so far in this user request`}. Use this review and the coverage below to decide whether the current user request is satisfied or further edits are needed.`, `# 当前树自动检查通过\n\n内容树校验和真实 Prompt Preview 均已通过;本次工具响应修改内容包中的 ${changed} 个文件${cumulative === changed ? "" : `,本次用户请求累计改动 ${cumulative} 个文件`}。请根据本次检查与下方覆盖报告判断当前用户要求是否已经满足,或是否还需修改。`)}\n\n${renderPlayCoverage(buildSettingAuthoringPlayCoverage(snapshot, review.preview, responseDiff), locale)}\n\n${renderWorldMaintenanceReport(review.preview.compilation.maintenance, changedShortRefs, locale)}`;
465
+ }
456
466
  return [
457
467
  localized(locale, "# Current-tree review needs repair", "# 当前树自动检查需要修复"),
458
468
  "",
@@ -561,8 +571,6 @@ function playAccessDetail(access) {
561
571
  return details[access];
562
572
  }
563
573
  function renderPlayCoverage(coverage, locale) {
564
- if (coverage === null)
565
- return localized(locale, "# Play-consumption coverage\n\nCoverage is unavailable until the real Prompt Preview succeeds.", "# 游玩读取覆盖\n\n真实 Prompt Preview 通过后才会提供覆盖报告。");
566
574
  const labels = locale === "zh-CN"
567
575
  ? {
568
576
  full_injected: "全文注入",
@@ -4,7 +4,7 @@ import { InvalidContentTreeError, } from "../content/ContentWorkspace.js";
4
4
  import { contentTreeFingerprint } from "../content/ContentTreeFingerprint.js";
5
5
  import { equalModelHostBinding } from "../model/ModelHost.js";
6
6
  import { settingImprovementPromptForBinding, } from "../play/FileNativePlayPresetStore.js";
7
- import { appendAuthoringUserMessage, ModelToolConversation, rememberAuthoringRequest, } from "../authoring/ModelToolConversation.js";
7
+ import { appendAuthoringUserMessage, ModelToolConversation, rememberAuthoringRequest, resumePausedAuthoringConversation, } from "../authoring/ModelToolConversation.js";
8
8
  import { summarizeStoredSettingImprovementSession, } from "./FileNativeSettingImprovementStore.js";
9
9
  import { SettingAuthoringTransaction, settingImprovementRuntimeContract, settingImprovementToolDefinitions, } from "./SettingAuthoringTransaction.js";
10
10
  class RecoveryWriteConflictError extends Error {
@@ -185,33 +185,48 @@ export class SettingImprovementSession {
185
185
  session = await this.#store.read(input.continuation.sessionId);
186
186
  assertPackage(session, input.packageId);
187
187
  }
188
- session = await this.#recoverIfNeeded(session);
189
- if (session.completedRequestIds.includes(requestId))
190
- return { session, run: null };
191
- const running = this.#conversation.running(session.sessionId);
192
- if (running !== null) {
193
- if (running.requestId !== requestId)
194
- throw new Error("Another message in this setting-improvement conversation is still running");
195
- return { session, run: running.promise };
196
- }
197
- const host = await this.#bindExistingModelHost(session.modelBinding);
198
- if (!equalModelHostBinding(session.modelBinding, host.binding()))
199
- throw new Error("No saved model connection matches this setting-improvement conversation");
200
- const { playPreset, bootstrap } = await this.#compileCurrent(input.packageId, session.modelBinding);
201
- session.schemaVersion = 3;
202
- session.requests ??= [];
203
- session.requests.push({
204
- requestId,
205
- modelItemStart: session.modelItems.length,
206
- bootstrap,
207
- playPreset: structuredClone(playPreset),
188
+ return this.#startRequest(session, requestId, async (next) => {
189
+ const { playPreset, bootstrap } = await this.#compileCurrent(input.packageId, next.modelBinding);
190
+ next.schemaVersion = 3;
191
+ next.requests ??= [];
192
+ next.requests.push({
193
+ requestId,
194
+ modelItemStart: next.modelItems.length,
195
+ bootstrap,
196
+ playPreset: structuredClone(playPreset),
197
+ });
198
+ appendAuthoringUserMessage(next, message, requestId);
208
199
  });
209
- appendAuthoringUserMessage(session, message, requestId);
210
- await this.#store.save(session);
211
- return { session, run: this.#startRun(session, host, requestId) };
212
200
  });
213
201
  return prepared.run ?? this.#view(prepared.session);
214
202
  }
203
+ /** Continues a paused conversation with the frozen request it paused in. */
204
+ async resume(input) {
205
+ const requestId = requiredRequestId(input.requestId);
206
+ const prepared = await this.#mutate(async () => {
207
+ const session = await this.#store.read(input.sessionId);
208
+ assertPackage(session, input.packageId);
209
+ return this.#startRequest(session, requestId, (next) => resumePausedAuthoringConversation(next));
210
+ });
211
+ return prepared.run ?? this.#view(prepared.session);
212
+ }
213
+ async #startRequest(stored, requestId, prepare) {
214
+ const session = await this.#recoverIfNeeded(stored);
215
+ if (session.completedRequestIds.includes(requestId))
216
+ return { session, run: null };
217
+ const running = this.#conversation.running(session.sessionId);
218
+ if (running !== null) {
219
+ if (running.requestId !== requestId)
220
+ throw new Error("Another message in this setting-improvement conversation is still running");
221
+ return { session, run: running.promise };
222
+ }
223
+ const host = await this.#bindExistingModelHost(session.modelBinding);
224
+ if (!equalModelHostBinding(session.modelBinding, host.binding()))
225
+ throw new Error("No saved model connection matches this setting-improvement conversation");
226
+ await prepare(session);
227
+ await this.#store.save(session);
228
+ return { session, run: this.#startRun(session, host, requestId) };
229
+ }
215
230
  async preview(packageId, sessionId) {
216
231
  const session = sessionId === undefined ? null : await this.#store.read(sessionId);
217
232
  if (session !== null)
@@ -307,7 +322,6 @@ export class SettingImprovementSession {
307
322
  view: (next) => this.#view(next),
308
323
  failure: {
309
324
  emptyResponse: "The model returned an empty tool-free authoring response",
310
- exchangeLimit: "Setting improvement exceeded 64 model exchanges for one user message",
311
325
  cancelled: "The setting-improvement response was cancelled. Complete tool responses already published to the current tree were retained.",
312
326
  interrupted: "The setting-improvement response was interrupted",
313
327
  },
@@ -246,7 +246,7 @@ function validateSession(value) {
246
246
  /[\r\n\0]/u.test(value.worldTitle) ||
247
247
  typeof value.epochId !== "string" ||
248
248
  (value.locale !== "en" && value.locale !== "zh-CN") ||
249
- !["ready", "running", "interrupted"].includes(String(value.runStatus)) ||
249
+ !["ready", "running", "paused", "interrupted"].includes(String(value.runStatus)) ||
250
250
  !finite(value.createdAt) ||
251
251
  !finite(value.updatedAt) ||
252
252
  value.updatedAt < value.createdAt ||
@@ -1,7 +1,7 @@
1
1
  import { authoringRequestPreviews } from "../authoring/AuthoringRequestSnapshot.js";
2
2
  import { createHash } from "node:crypto";
3
3
  import { emptyAggregatedModelUsage } from "../../protocol/modelUsage.js";
4
- import { appendAuthoringUserMessage, ModelToolConversation, rememberAuthoringRequest, } from "../authoring/ModelToolConversation.js";
4
+ import { appendAuthoringUserMessage, ModelToolConversation, rememberAuthoringRequest, resumePausedAuthoringConversation, } from "../authoring/ModelToolConversation.js";
5
5
  import { contentTreeFingerprint } from "../content/ContentTreeFingerprint.js";
6
6
  import { equalModelHostBinding } from "../model/ModelHost.js";
7
7
  import { settingImprovementPromptForBinding, } from "../play/FileNativePlayPresetStore.js";
@@ -215,6 +215,34 @@ export class WorldRevisionSession {
215
215
  });
216
216
  return prepared.run ?? this.#view(prepared.session);
217
217
  }
218
+ /** Continues a paused conversation within the revision it paused in. */
219
+ async resume(input) {
220
+ const requestId = requiredRequestId(input.requestId);
221
+ const prepared = await this.#mutate(async () => {
222
+ let session = await this.#store.readSession(input.sessionId);
223
+ assertWorld(session, input.worldId);
224
+ session = await this.#recoverIfNeeded(session);
225
+ if (session.completedRequestIds.includes(requestId))
226
+ return { session, run: null };
227
+ const running = this.#conversation.running(session.sessionId);
228
+ if (running !== null) {
229
+ if (running.requestId !== requestId)
230
+ throw new Error("Another message in this world-revision conversation is still running");
231
+ return { session, run: running.promise };
232
+ }
233
+ await this.#assertNoRunningConversation(input.worldId, session.sessionId);
234
+ const epoch = await this.#workspace.active(input.worldId);
235
+ if (epoch?.epochId !== session.epochId)
236
+ throw new WorldRevisionWorkspaceError("The paused conversation belongs to a revision that is no longer active; send a new message instead");
237
+ const host = await this.#bindExistingModelHost(session.modelBinding);
238
+ if (!equalModelHostBinding(session.modelBinding, host.binding()))
239
+ throw new Error("No saved model connection matches this world-revision conversation");
240
+ resumePausedAuthoringConversation(session);
241
+ await this.#store.saveSession(session);
242
+ return { session, run: this.#startRun(session, host, requestId) };
243
+ });
244
+ return prepared.run ?? this.#view(prepared.session);
245
+ }
218
246
  async preview(worldId, sessionId) {
219
247
  const session = sessionId === undefined ? null : await this.#store.readSession(sessionId);
220
248
  if (session !== null)
@@ -317,7 +345,6 @@ export class WorldRevisionSession {
317
345
  },
318
346
  view: (next) => this.#view(next),
319
347
  failure: {
320
- exchangeLimit: "World revision exceeded 64 model exchanges for one user message",
321
348
  cancelled: "The world-revision response was cancelled. Settled worktree changes were retained.",
322
349
  interrupted: "The world-revision response was interrupted",
323
350
  },
@@ -1,20 +1,52 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { inspect } from "node:util";
1
4
  import { resolveAppPaths } from "../runtime/config/appPaths.js";
2
5
  import { parseWebPort, startWebServer } from "./startWebServer.js";
3
6
  // Mobile entry: started inside the Android app's Node runtime. The native shell
4
7
  // owns the process lifecycle, so this module never registers signal handlers and
5
- // never calls process.exit(); a startup failure rejects and the native side reads
6
- // stderr.
7
- const staticRoot = process.env.NARRAEON_STATIC_ROOT?.trim();
8
- if (!staticRoot)
9
- throw new Error("NARRAEON_STATIC_ROOT is required for the mobile entry");
10
- const port = parseWebPort(process.env.NARRAEON_PORT);
8
+ // never calls process.exit(); a startup failure is logged and the native side
9
+ // reports it.
11
10
  const paths = resolveAppPaths(process.env);
12
- const server = await startWebServer({
13
- paths,
14
- staticRoot,
15
- host: "127.0.0.1",
16
- port,
11
+ const crashLogPath = join(paths.logRoot, "crash", "node-errors.log");
12
+ // Node shares the Android app process: an uncaught error would make Node call
13
+ // exit() and close the whole app. Record the error and keep serving instead; each
14
+ // Runtime write is atomic, so a failed request does not leave a half-written file.
15
+ process.on("uncaughtException", (error, origin) => {
16
+ recordCrash(origin, error);
17
17
  });
18
- const address = server.server.address();
19
- const listeningPort = typeof address === "object" && address !== null ? address.port : port;
20
- process.stdout.write(`NARRAEON_READY ${listeningPort}\n`);
18
+ process.on("unhandledRejection", (reason) => {
19
+ recordCrash("unhandledRejection", reason);
20
+ });
21
+ process.on("exit", (code) => {
22
+ recordCrash("exit", `process exiting with code ${code}`);
23
+ });
24
+ try {
25
+ const staticRoot = process.env.NARRAEON_STATIC_ROOT?.trim();
26
+ if (!staticRoot)
27
+ throw new Error("NARRAEON_STATIC_ROOT is required for the mobile entry");
28
+ const port = parseWebPort(process.env.NARRAEON_PORT);
29
+ const server = await startWebServer({
30
+ paths,
31
+ staticRoot,
32
+ host: "127.0.0.1",
33
+ port,
34
+ });
35
+ const address = server.server.address();
36
+ const listeningPort = typeof address === "object" && address !== null ? address.port : port;
37
+ process.stdout.write(`NARRAEON_READY ${listeningPort}\n`);
38
+ }
39
+ catch (error) {
40
+ recordCrash("startup", error);
41
+ }
42
+ function recordCrash(kind, value) {
43
+ const text = `${new Date().toISOString()} [${kind}] ${typeof value === "string" ? value : inspect(value, { depth: 4 })}\n`;
44
+ process.stderr.write(text);
45
+ try {
46
+ mkdirSync(join(paths.logRoot, "crash"), { recursive: true, mode: 0o700 });
47
+ appendFileSync(crashLogPath, text, { encoding: "utf8", mode: 0o600 });
48
+ }
49
+ catch {
50
+ // The stderr copy above still reaches logcat and the native stderr log.
51
+ }
52
+ }