@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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +2 -2
- package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/v1/builtin-tools/route.js +10 -1
- package/.next/standalone/.next/server/app/api/v1/builtin-tools/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/dashboard/currency/route.js +10 -5
- package/.next/standalone/.next/server/app/api/v1/dashboard/currency/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/page-capture/route.js +37 -3
- package/.next/standalone/.next/server/app/api/v1/page-capture/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/providers/[provider]/probe/route.js +9 -1
- package/.next/standalone/.next/server/app/api/v1/providers/[provider]/probe/route.js.map +1 -1
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js +33 -8
- package/.next/standalone/.next/server/app/api/v1/threads/[thread_id]/run/route.js.map +1 -1
- package/.next/standalone/.next/server/app/page.js +73 -204
- package/.next/standalone/.next/server/app/page.js.map +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/setup/page.js +1 -1
- package/.next/standalone/.next/server/app/setup/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/1718.js +159 -0
- package/.next/standalone/.next/server/chunks/1718.js.map +1 -0
- package/.next/standalone/.next/server/chunks/2082.js +6 -3
- package/.next/standalone/.next/server/chunks/2082.js.map +1 -1
- package/.next/standalone/.next/server/chunks/210.js +28 -0
- package/.next/standalone/.next/server/chunks/210.js.map +1 -1
- package/.next/standalone/.next/server/chunks/423.js +6 -3
- package/.next/standalone/.next/server/chunks/423.js.map +1 -1
- package/.next/standalone/.next/server/chunks/4631.js +37 -5
- package/.next/standalone/.next/server/chunks/4631.js.map +1 -1
- package/.next/standalone/.next/server/chunks/8167.js +255 -204
- package/.next/standalone/.next/server/chunks/8167.js.map +1 -1
- package/.next/standalone/.next/server/chunks/8866.js +38 -5
- package/.next/standalone/.next/server/chunks/8866.js.map +1 -1
- package/.next/standalone/.next/server/chunks/9032.js +8 -0
- package/.next/standalone/.next/server/chunks/9032.js.map +1 -1
- package/.next/standalone/.next/server/chunks/{7883.js → 9557.js} +15 -3
- package/.next/standalone/.next/server/chunks/9557.js.map +1 -0
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/middleware.js +6 -3
- package/.next/standalone/.next/server/pages/404.html +2 -2
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/proxy.js.map +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/{2351-68d8987bbe17ba2d.js → 2351-1ab119fb3b48f4c9.js} +258 -205
- package/.next/standalone/.next/static/chunks/2351-1ab119fb3b48f4c9.js.map +1 -0
- package/.next/standalone/.next/static/chunks/{9209-0d46118e502f8bf5.js → 4097-64691f9110cf167c.js} +14 -2
- package/.next/standalone/.next/static/chunks/4097-64691f9110cf167c.js.map +1 -0
- package/.next/standalone/.next/static/chunks/app/{page-2ab710949b62a638.js → page-145150e0468544e7.js} +74 -205
- package/.next/standalone/.next/static/chunks/app/page-145150e0468544e7.js.map +1 -0
- package/.next/standalone/.next/static/chunks/app/setup/{page-9a465b5fa755b3c3.js → page-a1463a9ace439ff7.js} +2 -2
- package/.next/standalone/.next/static/chunks/app/setup/{page-9a465b5fa755b3c3.js.map → page-a1463a9ace439ff7.js.map} +1 -1
- package/.next/standalone/.next/static/chunks/{webpack-ff5627013a5e3842.js → webpack-f4ac5c5f92cfd1c1.js} +13 -1
- package/.next/standalone/.next/static/chunks/webpack-f4ac5c5f92cfd1c1.js.map +1 -0
- package/.next/standalone/package.json +2 -1
- package/CHANGELOG.md +84 -0
- package/README.md +51 -26
- package/api/client.ts +10 -9
- package/app/api/v1/dashboard/currency/route.ts +7 -2
- package/app/api/v1/providers/[provider]/probe/route.ts +12 -1
- package/app/api/v1/threads/[thread_id]/run/route.ts +22 -8
- package/components/chat/InputBar.tsx +10 -1
- package/components/layout/AppShell.tsx +53 -17
- package/components/setup/PinKeypad.tsx +238 -0
- package/components/setup/ScreenLock.tsx +8 -173
- package/components/setup/UnlockScreen.tsx +25 -192
- package/lib/api/page-capture.test.ts +58 -0
- package/lib/api/page-capture.ts +31 -1
- package/lib/documents/remote/github.ts +16 -2
- package/lib/documents/remote/mail.ts +11 -2
- package/lib/lifecycle/shutdown.ts +9 -0
- package/lib/providers/github-copilot-auth.ts +2 -0
- package/lib/providers/github-copilot.ts +1 -0
- package/lib/tools/async-results.ts +11 -0
- package/package.json +2 -1
- package/scripts/install-to-system.ps1 +2 -2
- package/scripts/installed-launcher.ps1 +81 -17
- package/.next/standalone/.next/server/chunks/7883.js.map +0 -1
- package/.next/standalone/.next/static/chunks/2351-68d8987bbe17ba2d.js.map +0 -1
- package/.next/standalone/.next/static/chunks/9209-0d46118e502f8bf5.js.map +0 -1
- package/.next/standalone/.next/static/chunks/app/page-2ab710949b62a638.js.map +0 -1
- package/.next/standalone/.next/static/chunks/webpack-ff5627013a5e3842.js.map +0 -1
- /package/.next/standalone/.next/static/{ZKy7LJ3KXj2TIyKOg_fBH → WQdcnm9NyqpeNc0Z8_woo}/_buildManifest.js +0 -0
- /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
|
+
|
package/lib/api/page-capture.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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}` };
|
|
@@ -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
|
+
"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
|
|
249
|
-
-RestartCount
|
|
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.
|
|
124
|
-
#
|
|
125
|
-
#
|
|
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
|
|
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
|
-
$
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
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
|
}
|