@circuitwall/jarela 1.3.0 → 1.4.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.
Files changed (102) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  5. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/.next/standalone/.next/server/app/_not-found.html +2 -2
  15. package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  22. package/.next/standalone/.next/server/app/api/v1/builtin-tools/route.js +10 -1
  23. package/.next/standalone/.next/server/app/api/v1/builtin-tools/route.js.map +1 -1
  24. package/.next/standalone/.next/server/app/api/v1/dashboard/currency/route.js +10 -5
  25. package/.next/standalone/.next/server/app/api/v1/dashboard/currency/route.js.map +1 -1
  26. package/.next/standalone/.next/server/app/api/v1/page-capture/route.js +37 -3
  27. package/.next/standalone/.next/server/app/api/v1/page-capture/route.js.map +1 -1
  28. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/probe/route.js +9 -1
  29. package/.next/standalone/.next/server/app/api/v1/providers/[provider]/probe/route.js.map +1 -1
  30. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +33 -8
  31. package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js.map +1 -1
  32. package/.next/standalone/.next/server/app/page.js +73 -204
  33. package/.next/standalone/.next/server/app/page.js.map +1 -1
  34. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  36. package/.next/standalone/.next/server/app/setup/page.js +1 -1
  37. package/.next/standalone/.next/server/app/setup/page.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  39. package/.next/standalone/.next/server/chunks/1718.js +159 -0
  40. package/.next/standalone/.next/server/chunks/1718.js.map +1 -0
  41. package/.next/standalone/.next/server/chunks/2082.js +6 -3
  42. package/.next/standalone/.next/server/chunks/2082.js.map +1 -1
  43. package/.next/standalone/.next/server/chunks/210.js +28 -0
  44. package/.next/standalone/.next/server/chunks/210.js.map +1 -1
  45. package/.next/standalone/.next/server/chunks/423.js +6 -3
  46. package/.next/standalone/.next/server/chunks/423.js.map +1 -1
  47. package/.next/standalone/.next/server/chunks/4631.js +37 -5
  48. package/.next/standalone/.next/server/chunks/4631.js.map +1 -1
  49. package/.next/standalone/.next/server/chunks/8167.js +255 -204
  50. package/.next/standalone/.next/server/chunks/8167.js.map +1 -1
  51. package/.next/standalone/.next/server/chunks/8866.js +38 -5
  52. package/.next/standalone/.next/server/chunks/8866.js.map +1 -1
  53. package/.next/standalone/.next/server/chunks/9032.js +8 -0
  54. package/.next/standalone/.next/server/chunks/9032.js.map +1 -1
  55. package/.next/standalone/.next/server/chunks/{7883.js → 9557.js} +15 -3
  56. package/.next/standalone/.next/server/chunks/9557.js.map +1 -0
  57. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  58. package/.next/standalone/.next/server/middleware.js +6 -3
  59. package/.next/standalone/.next/server/pages/404.html +2 -2
  60. package/.next/standalone/.next/server/pages/500.html +1 -1
  61. package/.next/standalone/.next/server/proxy.js.map +1 -1
  62. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  63. package/.next/standalone/.next/static/chunks/{2351-68d8987bbe17ba2d.js → 2351-1ab119fb3b48f4c9.js} +258 -205
  64. package/.next/standalone/.next/static/chunks/2351-1ab119fb3b48f4c9.js.map +1 -0
  65. package/.next/standalone/.next/static/chunks/{9209-0d46118e502f8bf5.js → 4097-64691f9110cf167c.js} +14 -2
  66. package/.next/standalone/.next/static/chunks/4097-64691f9110cf167c.js.map +1 -0
  67. package/.next/standalone/.next/static/chunks/app/{page-2ab710949b62a638.js → page-145150e0468544e7.js} +74 -205
  68. package/.next/standalone/.next/static/chunks/app/page-145150e0468544e7.js.map +1 -0
  69. package/.next/standalone/.next/static/chunks/app/setup/{page-9a465b5fa755b3c3.js → page-a1463a9ace439ff7.js} +2 -2
  70. package/.next/standalone/.next/static/chunks/app/setup/{page-9a465b5fa755b3c3.js.map → page-a1463a9ace439ff7.js.map} +1 -1
  71. package/.next/standalone/.next/static/chunks/{webpack-ff5627013a5e3842.js → webpack-f4ac5c5f92cfd1c1.js} +13 -1
  72. package/.next/standalone/.next/static/chunks/webpack-f4ac5c5f92cfd1c1.js.map +1 -0
  73. package/.next/standalone/package.json +2 -1
  74. package/CHANGELOG.md +84 -0
  75. package/README.md +51 -26
  76. package/api/client.ts +10 -9
  77. package/app/api/v1/dashboard/currency/route.ts +7 -2
  78. package/app/api/v1/providers/[provider]/probe/route.ts +12 -1
  79. package/app/api/v1/threads/[thread_id]/run/route.ts +22 -8
  80. package/components/chat/InputBar.tsx +10 -1
  81. package/components/layout/AppShell.tsx +53 -17
  82. package/components/setup/PinKeypad.tsx +238 -0
  83. package/components/setup/ScreenLock.tsx +8 -173
  84. package/components/setup/UnlockScreen.tsx +25 -192
  85. package/lib/api/page-capture.test.ts +58 -0
  86. package/lib/api/page-capture.ts +31 -1
  87. package/lib/documents/remote/github.ts +16 -2
  88. package/lib/documents/remote/mail.ts +11 -2
  89. package/lib/lifecycle/shutdown.ts +9 -0
  90. package/lib/providers/github-copilot-auth.ts +2 -0
  91. package/lib/providers/github-copilot.ts +1 -0
  92. package/lib/tools/async-results.ts +11 -0
  93. package/package.json +2 -1
  94. package/scripts/install-to-system.ps1 +2 -2
  95. package/scripts/installed-launcher.ps1 +81 -17
  96. package/.next/standalone/.next/server/chunks/7883.js.map +0 -1
  97. package/.next/standalone/.next/static/chunks/2351-68d8987bbe17ba2d.js.map +0 -1
  98. package/.next/standalone/.next/static/chunks/9209-0d46118e502f8bf5.js.map +0 -1
  99. package/.next/standalone/.next/static/chunks/app/page-2ab710949b62a638.js.map +0 -1
  100. package/.next/standalone/.next/static/chunks/webpack-ff5627013a5e3842.js.map +0 -1
  101. /package/.next/standalone/.next/static/{ZKy7LJ3KXj2TIyKOg_fBH → WQdcnm9NyqpeNc0Z8_woo}/_buildManifest.js +0 -0
  102. /package/.next/standalone/.next/static/{ZKy7LJ3KXj2TIyKOg_fBH → WQdcnm9NyqpeNc0Z8_woo}/_ssgManifest.js +0 -0
@@ -307,3 +307,61 @@ describe("handlePageCapture — response shape", () => {
307
307
  });
308
308
  });
309
309
  });
310
+
311
+ describe("handlePageCapture — screenshot attachment", () => {
312
+ // 1x1 transparent PNG, base64-encoded (no data: prefix).
313
+ const tinyPng =
314
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
315
+
316
+ it("rejects screenshot with invalid base64", async () => {
317
+ const res = await handlePageCapture(makeReq({ ...validBody, screenshot: "not base64!!" }));
318
+ expect(res.status).toBe(400);
319
+ expect(addMessageMock).not.toHaveBeenCalled();
320
+ });
321
+
322
+ it("rejects screenshot exceeding the size cap", async () => {
323
+ const huge = "A".repeat(4_000_001);
324
+ const res = await handlePageCapture(makeReq({ ...validBody, screenshot: huge }));
325
+ expect(res.status).toBe(400);
326
+ });
327
+
328
+ it("persists user message as a JSON ContentPart[] with text + image when screenshot is present", async () => {
329
+ const res = await handlePageCapture(makeReq({ ...validBody, screenshot: tinyPng }));
330
+ expect(res.status).toBe(200);
331
+ const stored = addMessageMock.mock.calls[0][2] as string;
332
+ const parsed = JSON.parse(stored) as Array<{ type: string; text?: string; media_type?: string; data?: string }>;
333
+ expect(Array.isArray(parsed)).toBe(true);
334
+ expect(parsed).toHaveLength(2);
335
+ expect(parsed[0]).toMatchObject({ type: "text" });
336
+ expect(parsed[0].text).toContain("Captured from");
337
+ expect(parsed[0].text).toContain("Screenshot attached.");
338
+ expect(parsed[1]).toEqual({ type: "image", media_type: "image/png", data: tinyPng });
339
+ });
340
+
341
+ it("forwards the screenshot as a vision attachment to the silent observer run", async () => {
342
+ await handlePageCapture(makeReq({ ...validBody, screenshot: tinyPng }));
343
+ expect(runAgentTurnMock).toHaveBeenCalledWith(expect.objectContaining({
344
+ attachments: [{ type: "image", media_type: "image/png", data: tinyPng }],
345
+ }));
346
+ });
347
+
348
+ it("honors a custom screenshotMediaType", async () => {
349
+ await handlePageCapture(makeReq({ ...validBody, screenshot: tinyPng, screenshotMediaType: "image/jpeg" }));
350
+ const stored = addMessageMock.mock.calls[0][2] as string;
351
+ const parsed = JSON.parse(stored) as Array<{ type: string; media_type?: string }>;
352
+ expect(parsed[1].media_type).toBe("image/jpeg");
353
+ });
354
+
355
+ it("keeps the legacy string-content path when no screenshot is sent", async () => {
356
+ await handlePageCapture(makeReq(validBody));
357
+ const stored = addMessageMock.mock.calls[0][2] as string;
358
+ // Not JSON-parseable as an array — it's the legacy plaintext body.
359
+ expect(() => JSON.parse(stored)).toThrow();
360
+ expect(stored).toContain("Captured from");
361
+ expect(stored).not.toContain("Screenshot attached.");
362
+ expect(runAgentTurnMock).toHaveBeenCalledWith(expect.objectContaining({
363
+ attachments: undefined,
364
+ }));
365
+ });
366
+ });
367
+
@@ -13,12 +13,18 @@ import {
13
13
  } from "@/lib/stores/agent-configs";
14
14
  import { publish } from "@/lib/notifications/bus";
15
15
  import { runAgentTurn } from "@/lib/agents/agent-turn";
16
+ import type { ContentPart } from "@/lib/tools/types";
16
17
 
17
18
  // 100KB UTF-8 cap on captured text. The LLM context window is the real
18
19
  // constraint; this cap exists to keep a runaway "<body>" pick from
19
20
  // trashing the conversation. See ADR-0018.
20
21
  export const MAX_TEXT_BYTES = 100_000;
21
22
 
23
+ // Hard cap on the inline element screenshot (base64 chars). 4 MB of
24
+ // base64 ≈ 3 MB decoded — generous for a single cropped element while
25
+ // still bounding the SQLite row and the LLM vision payload.
26
+ export const MAX_SCREENSHOT_B64 = 4_000_000;
27
+
22
28
  // Preamble prepended to the LLM call for the silent observer run.
23
29
  // The captured content is already persisted in the DB — this wrapper
24
30
  // instructs the agent to observe without replying, matching bridge
@@ -37,6 +43,13 @@ const Body = z.object({
37
43
  tagName: z.string().max(64).optional(),
38
44
  text: z.string(),
39
45
  capturedAt: z.string().datetime(),
46
+ // Optional base64-encoded PNG of just the picked element (no data: URL
47
+ // prefix). The content script crops `chrome.tabs.captureVisibleTab`
48
+ // to the element bounding box before sending. When present, it is
49
+ // attached to the persisted user message as an image ContentPart so
50
+ // the chat UI renders it inline and vision-capable agents can see it.
51
+ screenshot: z.string().regex(/^[A-Za-z0-9+/=]+$/).max(MAX_SCREENSHOT_B64).optional(),
52
+ screenshotMediaType: z.string().regex(/^image\/[a-z0-9.+-]+$/).max(64).optional(),
40
53
  });
41
54
 
42
55
  function truncateUtf8(s: string, maxBytes: number): { text: string; truncated: boolean; originalBytes: number } {
@@ -102,12 +115,14 @@ function composeBody(args: {
102
115
  text: string;
103
116
  truncated: boolean;
104
117
  originalBytes: number;
118
+ hasScreenshot?: boolean;
105
119
  }): string {
106
120
  const heading = args.title
107
121
  ? `📎 Captured from [${args.title}](${args.url})`
108
122
  : `📎 Captured from <${args.url}>`;
109
123
  const lines = [heading];
110
124
  if (args.selector) lines.push(`Element: \`${args.selector}\``);
125
+ if (args.hasScreenshot) lines.push("Screenshot attached.");
111
126
  if (args.truncated) {
112
127
  lines.push(`> ⚠ Truncated to ${MAX_TEXT_BYTES.toLocaleString()} bytes (original was ${args.originalBytes.toLocaleString()} bytes)`);
113
128
  }
@@ -158,9 +173,23 @@ export async function handlePageCapture(req: Request): Promise<Response> {
158
173
  text,
159
174
  truncated,
160
175
  originalBytes,
176
+ hasScreenshot: Boolean(input.screenshot),
161
177
  });
162
178
 
163
- const msg = addMessage(thread_id, "user", messageBody, undefined, "page_capture");
179
+ // When a screenshot is included, persist the user turn as a multipart
180
+ // ContentPart[] (text + image) — that's the same shape the chat UI and
181
+ // agent runner expect for inline images, so the picture renders in the
182
+ // bubble on reload and vision-capable models can see it on the silent
183
+ // observer turn. Without a screenshot we keep the legacy string body
184
+ // to avoid touching messages that never had an image.
185
+ const screenshotPart: ContentPart | null = input.screenshot
186
+ ? { type: "image", media_type: input.screenshotMediaType ?? "image/png", data: input.screenshot }
187
+ : null;
188
+ const storedContent: string = screenshotPart
189
+ ? JSON.stringify([{ type: "text", text: messageBody }, screenshotPart] satisfies ContentPart[])
190
+ : messageBody;
191
+
192
+ const msg = addMessage(thread_id, "user", storedContent, undefined, "page_capture");
164
193
 
165
194
  // Fire a silent observer run so the agent ingests the captured context
166
195
  // without being forced to reply — matching bridge silent/observer mode.
@@ -170,6 +199,7 @@ export async function handlePageCapture(req: Request): Promise<Response> {
170
199
  thread_id,
171
200
  queue_source: "extension",
172
201
  message: `${SILENT_CAPTURE_PREAMBLE}\n\n${messageBody}`,
202
+ attachments: screenshotPart ? [screenshotPart] : undefined,
173
203
  user_category: "page_capture",
174
204
  assistant_category: "page_capture",
175
205
  silent: true,
@@ -216,10 +216,23 @@ async function runGithubPullsIndexer(source: DocumentSourceRow): Promise<GithubI
216
216
  if (Number.isFinite(sinceMs) && Number.isFinite(updatedMs) && updatedMs <= sinceMs) break outer;
217
217
 
218
218
  try {
219
- const [comments, reviews] = await Promise.all([
219
+ // allSettled so a failed comments fetch doesn't also discard
220
+ // the reviews (and vice versa); we still index what we have and
221
+ // count the partial as an error.
222
+ const [commentsRes, reviewsRes] = await Promise.allSettled([
220
223
  listIssueComments(auth, cfg.owner, cfg.repo, pr.number),
221
224
  listReviews(auth, cfg.owner, cfg.repo, pr.number),
222
225
  ]);
226
+ const comments = commentsRes.status === "fulfilled" ? commentsRes.value : [];
227
+ const reviews = reviewsRes.status === "fulfilled" ? reviewsRes.value : [];
228
+ if (commentsRes.status === "rejected") {
229
+ stats.errors++;
230
+ console.warn(`[github-indexer] pr#${pr.number} comments failed:`, commentsRes.reason);
231
+ }
232
+ if (reviewsRes.status === "rejected") {
233
+ stats.errors++;
234
+ console.warn(`[github-indexer] pr#${pr.number} reviews failed:`, reviewsRes.reason);
235
+ }
223
236
  const text = flattenPull(pr, comments, reviews);
224
237
  const res = await upsertRemoteDocument(source.id, {
225
238
  path: `github-pull://${cfg.owner}/${cfg.repo}/${pr.number}`,
@@ -229,8 +242,9 @@ async function runGithubPullsIndexer(source: DocumentSourceRow): Promise<GithubI
229
242
  });
230
243
  applyUpsert(stats, res);
231
244
  if (updated && (!highWater || updated > highWater)) highWater = updated;
232
- } catch {
245
+ } catch (err) {
233
246
  stats.errors++;
247
+ console.warn(`[github-indexer] pr#${pr.number} upsert failed:`, err);
234
248
  }
235
249
  }
236
250
  if (pulls.length < PR_PAGE_LIMIT) break;
@@ -182,7 +182,10 @@ async function indexGmailMail(row: DocumentSourceRow): Promise<RemoteIndexStats>
182
182
  const batch = list.messages ?? [];
183
183
  if (batch.length === 0) break;
184
184
 
185
- const results = await Promise.all(batch.slice(0, limit).map(async (entry) => {
185
+ // Use allSettled so a single 404/transient 5xx on one message
186
+ // doesn't bin the whole page (and stall the cursor on the next
187
+ // run). Failures get counted into stats.errors instead.
188
+ const settled = await Promise.allSettled(batch.slice(0, limit).map(async (entry) => {
186
189
  const msg = await googleFetch(
187
190
  auth,
188
191
  "Gmail",
@@ -212,10 +215,16 @@ async function indexGmailMail(row: DocumentSourceRow): Promise<RemoteIndexStats>
212
215
  });
213
216
  }));
214
217
 
215
- for (const [index, item] of results.entries()) {
218
+ for (const [index, outcome] of settled.entries()) {
216
219
  const id = batch[index]?.id ?? "";
217
220
  if (id) keep.add(`gmail://${id}`);
218
221
  stats.scanned++;
222
+ if (outcome.status === "rejected") {
223
+ stats.errors++;
224
+ console.warn(`[mail-indexer] message ${id} failed:`, outcome.reason);
225
+ continue;
226
+ }
227
+ const item = outcome.value;
219
228
  stats.added += item.status === "added" ? 1 : 0;
220
229
  stats.updated += item.status === "updated" ? 1 : 0;
221
230
  stats.unchanged += item.status === "unchanged" ? 1 : 0;
@@ -131,6 +131,15 @@ async function runShutdown(): Promise<void> {
131
131
  console.error("[jarela] waiting for runs failed:", err);
132
132
  }
133
133
 
134
+ // 4b. Stop the async-tool-results sweeper. Holds a setInterval that
135
+ // would otherwise keep the event loop alive past closeDb().
136
+ try {
137
+ const { stopAsyncResults } = await import("@/lib/tools/async-results");
138
+ stopAsyncResults();
139
+ } catch (err) {
140
+ console.error("[jarela] stopping async-results sweeper failed:", err);
141
+ }
142
+
134
143
  // 5. Close the DB. WAL is checkpointed so the next boot is fast and we
135
144
  // leave no stale -shm/-wal sidecars on disk.
136
145
  try {
@@ -44,6 +44,7 @@ export async function startDeviceFlow(): Promise<DeviceCodeResponse> {
44
44
  "User-Agent": "Jarela/1.0",
45
45
  },
46
46
  body: JSON.stringify({ client_id: COPILOT_CLIENT_ID, scope: SCOPE }),
47
+ signal: AbortSignal.timeout(30_000),
47
48
  });
48
49
  if (!res.ok) {
49
50
  const body = await res.text().catch(() => res.statusText);
@@ -69,6 +70,7 @@ export async function pollDeviceFlow(device_code: string): Promise<PollResult> {
69
70
  device_code,
70
71
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
71
72
  }),
73
+ signal: AbortSignal.timeout(30_000),
72
74
  });
73
75
  if (!res.ok) {
74
76
  return { status: "error", error: `HTTP ${res.status}` };
@@ -53,6 +53,7 @@ async function getCopilotToken(pat: string): Promise<string> {
53
53
  Authorization: `token ${pat}`,
54
54
  "User-Agent": "Jarela/1.0",
55
55
  },
56
+ signal: AbortSignal.timeout(30_000),
56
57
  });
57
58
 
58
59
  if (!res.ok) {
@@ -57,6 +57,17 @@ function ensureSweeper(): void {
57
57
  (sweeper as unknown as { unref?: () => void }).unref?.();
58
58
  }
59
59
 
60
+ /**
61
+ * Tear down the sweeper. Called from the shutdown drain so the timer
62
+ * isn't keeping the event loop alive past close-time. Idempotent.
63
+ */
64
+ export function stopAsyncResults(): void {
65
+ if (sweeper) {
66
+ clearInterval(sweeper);
67
+ sweeper = null;
68
+ }
69
+ }
70
+
60
71
  /**
61
72
  * Carve out a slot for a new async tool call and return its key.
62
73
  * The key is opaque and URL-safe — the agent treats it as a token.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@circuitwall/jarela",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "Jarela — local chat interface for LangGraph agents (multi-provider, single-process, SQLite-backed).",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Andrew Ge Wu",
@@ -100,6 +100,7 @@
100
100
  "test:live:isolated:full": "node scripts/live-test-isolated.mjs --llm",
101
101
  "test:e2e": "playwright test",
102
102
  "test:e2e:ui": "playwright test --ui",
103
+ "promo:record": "node scripts/promo-record.mjs",
103
104
  "release:docker": "node scripts/release-docker.mjs",
104
105
  "release:docker:dry": "node scripts/release-docker.mjs --dry-run"
105
106
  },
@@ -245,8 +245,8 @@ $settings = New-ScheduledTaskSettingsSet `
245
245
  -StartWhenAvailable `
246
246
  -Hidden `
247
247
  -ExecutionTimeLimit (New-TimeSpan -Seconds 0) `
248
- -RestartInterval (New-TimeSpan -Minutes 1) `
249
- -RestartCount 999 `
248
+ -RestartInterval (New-TimeSpan -Minutes 5) `
249
+ -RestartCount 3 `
250
250
  -MultipleInstances IgnoreNew
251
251
 
252
252
  $task = New-ScheduledTask `
@@ -120,41 +120,105 @@ $env:PORT = "$Port"
120
120
  $env:HOSTNAME = '127.0.0.1'
121
121
  $env:NODE_ENV = 'production'
122
122
 
123
- # Separate files for the node child's stdout/stderr. The launcher writes its
124
- # own supervisor messages to $LogFile; Start-Process cannot share that handle
125
- # with a child or it fails silently on Windows ("file is in use").
123
+ # Separate files for the node child's stdout/stderr. We append (>>) instead
124
+ # of truncating each spawn so a crash-and-respawn cycle keeps the prior
125
+ # stderr lines around for diagnosis.
126
126
  $ServerOut = Join-Path $LogDir 'server.out.log'
127
127
  $ServerErr = Join-Path $LogDir 'server.err.log'
128
128
 
129
+ # Spawn node and reliably capture stdout/stderr to files. Background:
130
+ # `Start-Process -NoNewWindow -RedirectStandardOutput X -RedirectStandardError Y`
131
+ # silently drops both streams when the parent chain is wscript -> powershell
132
+ # (i.e. when there is no console attached to the PowerShell host). Under that
133
+ # setup, server.out.log / server.err.log stay 0 bytes forever, hiding crash
134
+ # reasons. Switching to a raw `[System.Diagnostics.Process]` with async
135
+ # `BeginOutputReadLine`/`BeginErrorReadLine` event handlers works regardless
136
+ # of console attachment because the read loop runs on a .NET worker thread.
137
+ function Start-NodeChild([string]$nodeExe, [string]$workDir, [string]$outFile, [string]$errFile) {
138
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
139
+ $psi.FileName = $nodeExe
140
+ $psi.Arguments = 'server.js'
141
+ $psi.WorkingDirectory = $workDir
142
+ $psi.UseShellExecute = $false
143
+ $psi.RedirectStandardOutput = $true
144
+ $psi.RedirectStandardError = $true
145
+ $psi.CreateNoWindow = $true
146
+ # Env (PORT, HOSTNAME, NODE_ENV set above) is inherited by default.
147
+
148
+ $proc = New-Object System.Diagnostics.Process
149
+ $proc.StartInfo = $psi
150
+ $proc.EnableRaisingEvents = $true
151
+
152
+ # StreamWriters with AutoFlush=true so every line lands on disk immediately.
153
+ $outFs = [System.IO.File]::Open($outFile, [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read)
154
+ $errFs = [System.IO.File]::Open($errFile, [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write, [System.IO.FileShare]::Read)
155
+ $outSw = New-Object System.IO.StreamWriter($outFs, [System.Text.Encoding]::UTF8)
156
+ $errSw = New-Object System.IO.StreamWriter($errFs, [System.Text.Encoding]::UTF8)
157
+ $outSw.AutoFlush = $true
158
+ $errSw.AutoFlush = $true
159
+
160
+ $outEvent = Register-ObjectEvent -InputObject $proc -EventName OutputDataReceived -MessageData $outSw -Action {
161
+ if ($EventArgs.Data -ne $null) { $Event.MessageData.WriteLine($EventArgs.Data) }
162
+ }
163
+ $errEvent = Register-ObjectEvent -InputObject $proc -EventName ErrorDataReceived -MessageData $errSw -Action {
164
+ if ($EventArgs.Data -ne $null) { $Event.MessageData.WriteLine($EventArgs.Data) }
165
+ }
166
+
167
+ if (-not $proc.Start()) { return $null }
168
+ $proc.BeginOutputReadLine()
169
+ $proc.BeginErrorReadLine()
170
+
171
+ return [PSCustomObject]@{
172
+ Process = $proc
173
+ OutWriter = $outSw
174
+ ErrWriter = $errSw
175
+ OutFs = $outFs
176
+ ErrFs = $errFs
177
+ OutEvent = $outEvent
178
+ ErrEvent = $errEvent
179
+ }
180
+ }
181
+
129
182
  # Supervisor loop: respawn server.js if it exits. Bail after too many rapid
130
- # restarts so Task Scheduler can retry the whole task.
183
+ # restarts (3 in 60s) so Task Scheduler can decide. With an encrypted master
184
+ # key, every restart costs the user a manual PIN entry, so we prefer to fail
185
+ # loudly rather than churn silently.
131
186
  $restartCount = 0
132
187
  $windowStart = Get-Date
133
188
 
134
189
  while ($true) {
135
190
  Write-Log "Starting 'node server.js' on http://127.0.0.1:$Port (out=$ServerOut)"
136
- $proc = Start-Process -FilePath $node `
137
- -ArgumentList 'server.js' `
138
- -WorkingDirectory $InstallDir `
139
- -NoNewWindow `
140
- -PassThru `
141
- -RedirectStandardOutput $ServerOut `
142
- -RedirectStandardError $ServerErr
143
- if (-not $proc) {
144
- Write-Log "FATAL: Start-Process returned null for node server.js"
191
+ $child = Start-NodeChild -nodeExe $node -workDir $InstallDir -outFile $ServerOut -errFile $ServerErr
192
+ if (-not $child -or -not $child.Process) {
193
+ Write-Log "FATAL: failed to start node server.js"
145
194
  Start-Sleep -Seconds 5
146
195
  exit 1
147
196
  }
148
- Write-Log "spawned node PID $($proc.Id)"
149
- Wait-ProcessExit $proc.Id
150
- Write-Log "server.js exited (PID $($proc.Id))"
197
+ $childPid = $child.Process.Id
198
+ Write-Log "spawned node PID $childPid"
199
+
200
+ Wait-ProcessExit $childPid
201
+ $exitCode = $null
202
+ try { $exitCode = $child.Process.ExitCode } catch {}
203
+
204
+ # Drain remaining async reads, then unregister handlers and close files.
205
+ try { $child.Process.WaitForExit() } catch {}
206
+ try { Unregister-Event -SourceIdentifier $child.OutEvent.Name -ErrorAction SilentlyContinue } catch {}
207
+ try { Unregister-Event -SourceIdentifier $child.ErrEvent.Name -ErrorAction SilentlyContinue } catch {}
208
+ try { $child.OutWriter.Dispose() } catch {}
209
+ try { $child.ErrWriter.Dispose() } catch {}
210
+ try { $child.OutFs.Dispose() } catch {}
211
+ try { $child.ErrFs.Dispose() } catch {}
212
+ try { $child.Process.Dispose() } catch {}
213
+
214
+ Write-Log "server.js exited (PID $childPid, exit=$exitCode)"
151
215
 
152
216
  if (((Get-Date) - $windowStart).TotalSeconds -gt 60) {
153
217
  $restartCount = 0
154
218
  $windowStart = Get-Date
155
219
  }
156
220
  $restartCount++
157
- if ($restartCount -ge 5) {
221
+ if ($restartCount -ge 3) {
158
222
  Write-Log "Too many rapid restarts ($restartCount in <60s). Exiting; Task Scheduler will retry."
159
223
  exit 1
160
224
  }