@fungi.computer/knapsack 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/files.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { nanoid } from "@fungi.computer/cairn";
2
- import { Mycelium, } from "@fungi.computer/mycelium";
2
+ import { MAX_EXECUTION_IMAGES, Mycelium, } from "@fungi.computer/mycelium";
3
3
  import * as Effect from "effect/Effect";
4
4
  import * as Stream from "effect/Stream";
5
5
  import { patchInputSchema, patchOutputSchema } from "./files-patch.js";
@@ -7,11 +7,14 @@ export { patchInputSchema, patchOutputSchema } from "./files-patch.js";
7
7
  import { patchGrammar } from "./patch-grammar.js";
8
8
  import { diffLines, formatPatch, structuredPatch } from "diff";
9
9
  import { z } from "zod";
10
- import { maximumReadBytes, pathSchema, boundedTextSchema, readInputSchema, readOutputSchema, } from "./files-read.js";
11
- export { readInputSchema, readOutputSchema };
10
+ import { maximumImageBytes, maximumReadBytes, pathSchema, boundedTextSchema, readInputSchema, readGuestOutputSchema, readOutputSchema, } from "./files-read.js";
11
+ export { readGuestOutputSchema, readInputSchema, readOutputSchema };
12
12
  const maximumPatternCharacters = 512;
13
13
  const maximumResultEntries = 256;
14
+ const defaultSearchResults = 100;
15
+ const maximumFoundLineCharacters = 500;
14
16
  const maximumReadBytesText = String(maximumReadBytes);
17
+ const readDescription = `Read one bounded UTF-8 file page, or a whole png, jpeg, gif or webp image returned as an image you can look at (sent at most ${String(maximumImageBytes / 1_024)} KiB; the host may shrink it and refuses an image it cannot read or fit).`;
15
18
  const maximumUnifiedPatchBytes = 65_536;
16
19
  const utf8 = new TextEncoder();
17
20
  /** Expected Files edit refusal, kept distinct from unexpected host failures. */
@@ -25,70 +28,45 @@ export class FilesEditError extends Error {
25
28
  this.name = "FilesEditError";
26
29
  }
27
30
  }
28
- /** Bounded directory listing request. */
29
- export const listInputSchema = z.strictObject({
30
- path: pathSchema,
31
- limit: z
32
- .number()
33
- .int()
34
- .min(1)
35
- .max(maximumResultEntries)
36
- .default(maximumResultEntries),
37
- });
38
- const listEntrySchema = z.lazy(() => z.strictObject({
39
- path: pathSchema,
40
- name: z.string().min(1),
41
- type: z.enum(["file", "directory", "symlink"]),
42
- size: z.number().int().nonnegative(),
43
- modifiedAt: z.number(),
31
+ const searchLimitSchema = z
32
+ .number()
33
+ .int()
34
+ .min(1)
35
+ .max(maximumResultEntries)
36
+ .default(defaultSearchResults);
37
+ const searchPatternSchema = z.string().min(1).max(maximumPatternCharacters);
38
+ /** List one directory level. */
39
+ export const listInputSchema = z.lazy(() => z.strictObject({
40
+ path: pathSchema.default("."),
41
+ limit: searchLimitSchema,
44
42
  }));
45
- /** Directory entries and an explicit truncation flag. */
46
- export const listOutputSchema = z.lazy(() => z.strictObject({
47
- path: pathSchema,
48
- entries: z.array(listEntrySchema).max(maximumResultEntries),
49
- truncated: z.boolean(),
50
- }));
51
- /** Bounded path-pattern search request. */
43
+ /** Match paths below a directory by glob. */
52
44
  export const globInputSchema = z.lazy(() => z.strictObject({
53
- path: pathSchema,
54
- pattern: z.string().min(1).max(maximumPatternCharacters),
55
- limit: z
56
- .number()
57
- .int()
58
- .min(1)
59
- .max(maximumResultEntries)
60
- .default(maximumResultEntries),
45
+ path: pathSchema.default("."),
46
+ pattern: searchPatternSchema,
47
+ limit: searchLimitSchema,
61
48
  }));
62
- /** Matching paths and an explicit truncation flag. */
63
- export const globOutputSchema = z.lazy(() => z.strictObject({
64
- path: pathSchema,
65
- matches: z.array(pathSchema).max(maximumResultEntries),
66
- truncated: z.boolean(),
67
- }));
68
- /** Bounded content-search request with optional regular-expression matching. */
49
+ /** Search file content, optionally restricted to matching paths. */
69
50
  export const grepInputSchema = z.lazy(() => z.strictObject({
70
- path: pathSchema,
71
- pattern: z.string().min(1).max(maximumPatternCharacters),
51
+ path: pathSchema.default("."),
52
+ pattern: searchPatternSchema,
53
+ include: searchPatternSchema.optional(),
72
54
  regex: z.boolean().default(false),
73
55
  ignoreCase: z.boolean().default(false),
74
- limit: z
75
- .number()
76
- .int()
77
- .min(1)
78
- .max(maximumResultEntries)
79
- .default(maximumResultEntries),
56
+ limit: searchLimitSchema,
80
57
  }));
81
- /** Content matches and an explicit truncation flag. */
82
- export const grepOutputSchema = z.lazy(() => z.strictObject({
58
+ /** One found path, with the matching line when the search was by content. */
59
+ const foundSchema = z.lazy(() => z.strictObject({
83
60
  path: pathSchema,
84
- matches: z
85
- .array(z.strictObject({
86
- path: pathSchema,
87
- line: z.number().int().positive(),
88
- text: z.string(),
89
- }))
90
- .max(maximumResultEntries),
91
- truncated: z.boolean(),
61
+ type: z.enum(["file", "directory", "symlink"]).exactOptional(),
62
+ line: z.number().int().positive().exactOptional(),
63
+ text: z.string().exactOptional(),
64
+ }));
65
+ /** Found paths or matching lines, and which cap (if any) cut the search short. */
66
+ export const searchOutputSchema = z.lazy(() => z.strictObject({
67
+ path: pathSchema,
68
+ results: z.array(foundSchema).max(maximumResultEntries),
69
+ truncated: z.enum(["limit", "scan"]).nullable(),
92
70
  }));
93
71
  /** Text replacement request decoded before the captured Files port is called. */
94
72
  export const editInputSchema = z.lazy(() => z.strictObject({
@@ -116,6 +94,7 @@ export const filesEditFailureSchema = z.lazy(() => z.strictObject({
116
94
  "ambiguous",
117
95
  "file-too-large",
118
96
  "partial-character",
97
+ "not-text",
119
98
  "unchanged",
120
99
  "conflict",
121
100
  ]),
@@ -281,6 +260,9 @@ async function applyEdit(port, input, context) {
281
260
  offset: 0,
282
261
  limit: maximumReadBytes,
283
262
  }, context));
263
+ if ("image" in current) {
264
+ throw new FilesEditError("not-text", input.path, "file is an image, not editable text");
265
+ }
284
266
  if (current.truncated) {
285
267
  throw new FilesEditError("file-too-large", input.path, `File exceeds ${maximumReadBytesText} bytes; use files.patch for a small change`);
286
268
  }
@@ -357,6 +339,12 @@ function runFileOperation(invoke) {
357
339
  function runPatch(invoke, input) {
358
340
  return runFileOperation((context) => invoke(input, context));
359
341
  }
342
+ /** Keep model parameters while omitting the decoder's schema-document marker. */
343
+ function toolParameters(schema) {
344
+ const parameters = z.toJSONSchema(schema, { io: "input" });
345
+ delete parameters.$schema;
346
+ return parameters;
347
+ }
360
348
  /** Direct presentation of the same validated Files operation. */
361
349
  function patchTool(invokePatch) {
362
350
  const tool = {
@@ -368,7 +356,7 @@ function patchTool(invokePatch) {
368
356
  name: "apply_patch",
369
357
  description: "Apply a Codex patch to the selected Files root. Example:\n*** Begin Patch\n*** Update File: path/to/file\n@@\n context line\n-old line\n+new line\n*** End Patch\nUse @@ alone or followed by an existing context line; unified-diff @@ line numbers are not supported. Add/Delete File and optional Move to are also supported. Paths stay inside the root; symlinks and parent traversal are rejected. A failure returns recovery guidance and may follow earlier file writes; inspect the result before retrying. Prefer this to shell or Python replacement.",
370
358
  // Model-facing parameters derived from the one Zod decoder `prepare` uses.
371
- parameters: z.toJSONSchema(patchInputSchema),
359
+ parameters: toolParameters(patchInputSchema),
372
360
  constrainedSampling: {
373
361
  type: "grammar",
374
362
  variants: { openai_lark: patchGrammar },
@@ -397,8 +385,109 @@ function patchTool(invokePatch) {
397
385
  };
398
386
  return tool;
399
387
  }
388
+ // One explicit scope difference, shared by both projections of each search.
389
+ const searchScopeDescription = "Search skips .git and gitignored paths.";
390
+ const searchDescriptions = {
391
+ list: `List one directory level. ${searchScopeDescription}`,
392
+ glob: `Find paths by glob. ${searchScopeDescription}`,
393
+ grep: `Search file content. ${searchScopeDescription}`,
394
+ };
395
+ /** Long matching lines would flood the context; the cap is applied once, here. */
396
+ function capFoundLines(output) {
397
+ return {
398
+ ...output,
399
+ results: output.results.map((found) => found.text === undefined ||
400
+ found.text.length <= maximumFoundLineCharacters
401
+ ? found
402
+ : {
403
+ ...found,
404
+ text: `${found.text.slice(0, maximumFoundLineCharacters)}…`,
405
+ }),
406
+ };
407
+ }
408
+ /** Plain lines for the model: `path`, or `path:line: text`, then why it stopped early. */
409
+ function renderFound(output) {
410
+ const lines = output.results.map((found) => found.line === undefined
411
+ ? `${found.path}${found.type === "directory" ? "/" : ""}`
412
+ : `${found.path}:${String(found.line)}: ${found.text ?? ""}`);
413
+ if (lines.length === 0 && output.truncated === null)
414
+ lines.push("(nothing found)");
415
+ if (output.truncated === "limit")
416
+ lines.push("[truncated: result limit reached; raise limit or narrow path]");
417
+ if (output.truncated === "scan")
418
+ lines.push("[truncated: scan limit reached before the search finished; narrow path]");
419
+ return lines.join("\n");
420
+ }
421
+ function presentSearch(result) {
422
+ return {
423
+ content: [{ type: "text", text: renderFound(result) }],
424
+ isError: false,
425
+ };
426
+ }
427
+ /**
428
+ * Guest code and MCP callers never receive base64: the image goes through the
429
+ * execution's image collector (the `images.view` path) and the value is a caption.
430
+ */
431
+ export function presentReadForGuest(result, context) {
432
+ if (!("image" in result))
433
+ return result;
434
+ if (context.images === undefined)
435
+ throw new Error("Images can only be read inside execute or through the read tool.");
436
+ const { mimeType, data, bytes } = result.image;
437
+ const ref = context.images.push({
438
+ data,
439
+ mimeType,
440
+ path: result.path,
441
+ bytes,
442
+ });
443
+ return { path: result.path, image: { ref, mimeType, bytes } };
444
+ }
445
+ /**
446
+ * Images reach the model as an image part; text pages stay JSON text. The
447
+ * Files surface counts the images it shows and refuses past the run limit.
448
+ */
449
+ function presentRead(admit) {
450
+ return (result) => {
451
+ if (!("image" in result))
452
+ return {
453
+ content: [{ type: "text", text: JSON.stringify(result) }],
454
+ isError: false,
455
+ };
456
+ if (!admit())
457
+ return {
458
+ content: [
459
+ {
460
+ type: "text",
461
+ text: `Image limit reached for this run (max ${String(MAX_EXECUTION_IMAGES)} images). Work with the images already shown.`,
462
+ },
463
+ ],
464
+ isError: true,
465
+ };
466
+ return {
467
+ content: [
468
+ {
469
+ type: "text",
470
+ text: JSON.stringify({
471
+ path: result.path,
472
+ mimeType: result.image.mimeType,
473
+ bytes: result.image.bytes,
474
+ }),
475
+ },
476
+ {
477
+ type: "image",
478
+ data: result.image.data,
479
+ mimeType: result.image.mimeType,
480
+ },
481
+ ],
482
+ isError: false,
483
+ };
484
+ };
485
+ }
400
486
  /** Present the very same Files operation and decoder used behind execute. */
401
- function readOnlyTool(name, description, inputSchema, outputSchema, invoke) {
487
+ function readOnlyTool(name, description, inputSchema, outputSchema, invoke, present = (result) => ({
488
+ content: [{ type: "text", text: JSON.stringify(result) }],
489
+ isError: false,
490
+ })) {
402
491
  const readOnly = {
403
492
  [fileToolBrand]: true,
404
493
  module: filesModuleId,
@@ -407,7 +496,8 @@ function readOnlyTool(name, description, inputSchema, outputSchema, invoke) {
407
496
  descriptor: {
408
497
  name,
409
498
  description,
410
- parameters: z.toJSONSchema(inputSchema),
499
+ // Input side: defaulted fields are optional for the model.
500
+ parameters: toolParameters(inputSchema),
411
501
  },
412
502
  executionMode: "sequential",
413
503
  prepare: (call) => Effect.try({
@@ -419,13 +509,7 @@ function readOnlyTool(name, description, inputSchema, outputSchema, invoke) {
419
509
  execute: () => Stream.fromEffect(runFileOperation(async (context) => outputSchema.parse(await invoke(value, context))).pipe(Effect.map((result) => ({
420
510
  type: "result",
421
511
  result: {
422
- content: [
423
- {
424
- type: "text",
425
- text: JSON.stringify(result),
426
- },
427
- ],
428
- isError: false,
512
+ ...present(result),
429
513
  },
430
514
  })))),
431
515
  }))),
@@ -441,38 +525,40 @@ export function files(input) {
441
525
  const invokePatch = patch === undefined
442
526
  ? undefined
443
527
  : async (value, context) => patchOutputSchema.parse(await patch(patchInputSchema.parse(value), context));
528
+ /** Images the direct `read` tool has shown in this Files surface's run. */
529
+ let directImages = 0;
444
530
  const operations = {
445
531
  read: Mycelium.operation({
446
532
  replay: "safe",
447
533
  access: "read",
448
534
  input: readInputSchema,
449
- output: readOutputSchema,
450
- description: describe("Read one bounded UTF-8 file page."),
451
- execute: async (operationInput, context) => input.port.read(operationInput, context),
535
+ output: readGuestOutputSchema,
536
+ description: describe(readDescription),
537
+ execute: async (operationInput, context) => presentReadForGuest(await input.port.read(operationInput, context), context),
452
538
  }),
453
539
  list: Mycelium.operation({
454
540
  replay: "safe",
455
541
  access: "read",
456
542
  input: listInputSchema,
457
- output: listOutputSchema,
458
- description: describe("List one bounded directory level."),
459
- execute: async (operationInput, context) => input.port.list(operationInput, context),
543
+ output: searchOutputSchema,
544
+ description: describe(searchDescriptions.list),
545
+ execute: async (operationInput, context) => capFoundLines(await input.port.list(operationInput, context)),
460
546
  }),
461
547
  glob: Mycelium.operation({
462
548
  replay: "safe",
463
549
  access: "read",
464
550
  input: globInputSchema,
465
- output: globOutputSchema,
466
- description: describe("Find bounded paths by pattern."),
467
- execute: async (operationInput, context) => input.port.glob(operationInput, context),
551
+ output: searchOutputSchema,
552
+ description: describe(searchDescriptions.glob),
553
+ execute: async (operationInput, context) => capFoundLines(await input.port.glob(operationInput, context)),
468
554
  }),
469
555
  grep: Mycelium.operation({
470
556
  replay: "safe",
471
557
  access: "read",
472
558
  input: grepInputSchema,
473
- output: grepOutputSchema,
474
- description: describe("Search bounded file content."),
475
- execute: async (operationInput, context) => input.port.grep(operationInput, context),
559
+ output: searchOutputSchema,
560
+ description: describe(searchDescriptions.grep),
561
+ execute: async (operationInput, context) => capFoundLines(await input.port.grep(operationInput, context)),
476
562
  }),
477
563
  edit: Mycelium.operation({
478
564
  replay: "unsafe",
@@ -496,10 +582,10 @@ export function files(input) {
496
582
  return Object.freeze({
497
583
  tools: Object.freeze([
498
584
  ...(invokePatch === undefined ? [] : [patchTool(invokePatch)]),
499
- readOnlyTool("read", describe("Read one bounded UTF-8 file page."), readInputSchema, readOutputSchema, input.port.read),
500
- readOnlyTool("list", describe("List one bounded directory level."), listInputSchema, listOutputSchema, input.port.list),
501
- readOnlyTool("glob", describe("Find bounded paths by pattern."), globInputSchema, globOutputSchema, input.port.glob),
502
- readOnlyTool("grep", describe("Search bounded file content."), grepInputSchema, grepOutputSchema, input.port.grep),
585
+ readOnlyTool("read", describe(readDescription), readInputSchema, readOutputSchema, input.port.read, presentRead(() => (directImages += 1) <= MAX_EXECUTION_IMAGES)),
586
+ readOnlyTool("list", describe(searchDescriptions.list), listInputSchema, searchOutputSchema, async (value, context) => capFoundLines(await input.port.list(value, context)), presentSearch),
587
+ readOnlyTool("glob", describe(searchDescriptions.glob), globInputSchema, searchOutputSchema, async (value, context) => capFoundLines(await input.port.glob(value, context)), presentSearch),
588
+ readOnlyTool("grep", describe(searchDescriptions.grep), grepInputSchema, searchOutputSchema, async (value, context) => capFoundLines(await input.port.grep(value, context)), presentSearch),
503
589
  ]),
504
590
  module: Mycelium.module({
505
591
  id: filesModuleId,
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type CapabilitySource, type InvocationContext, type MyceliumJsonValue } from "@fungi.computer/mycelium";
2
2
  import { files, type FilesConnectionOptions } from "./files.js";
3
3
  import { mcpServers } from "./mcp.js";
4
+ import { ui } from "./ui.js";
4
5
  /** Generic advertised-call interface supplied by one Computer provider. */
5
6
  export type KnapsackComputer = Readonly<{
6
7
  /** Knapsack stops waiting when `signal` aborts, whether or not the provider honours it. */
@@ -65,6 +66,9 @@ export declare const Knapsack: Readonly<{
65
66
  files: typeof files;
66
67
  /** Connect a changing list of remote MCP servers, reporting unavailable ones independently. */
67
68
  mcpServers: typeof mcpServers;
69
+ /** Connect the open App's live Whistle commands to each Agent Session. */
70
+ ui: typeof ui;
68
71
  }>;
72
+ export type { UiConnection, UiConnectionOptions } from "./ui.js";
69
73
  export { McpServerUnavailableError, type McpServerConnection, type McpServerUnavailableReason, type McpServersConnectionOptions, } from "./mcp.js";
70
- export { FilesEditError, filesEditFailureSchema, type FilesEditErrorReason, type FilesConnectionOptions, type FilesPort, type FilesPatch, type FilesSurface, edit, readInputSchema, readOutputSchema, listInputSchema, listOutputSchema, globInputSchema, globOutputSchema, grepInputSchema, grepOutputSchema, editInputSchema, editOutputSchema, patchInputSchema, patchOutputSchema, type FileTool, type FileToolName, } from "./files.js";
74
+ export { FilesEditError, filesEditFailureSchema, type FilesEditErrorReason, type FilesConnectionOptions, type FilesPort, type FilesPatch, type FilesSurface, edit, readInputSchema, readOutputSchema, listInputSchema, globInputSchema, grepInputSchema, searchOutputSchema, readGuestOutputSchema, presentReadForGuest, editInputSchema, editOutputSchema, patchInputSchema, patchOutputSchema, type FileTool, type FileToolName, } from "./files.js";
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@ import * as Result from "effect/Result";
4
4
  import { z } from "zod";
5
5
  import { files } from "./files.js";
6
6
  import { mcpServers } from "./mcp.js";
7
+ import { ui } from "./ui.js";
7
8
  const maximumCapabilities = 256;
8
9
  const describeConcurrency = 8;
9
10
  const defaultDescribeTimeoutMillis = 10_000;
@@ -173,6 +174,8 @@ export const Knapsack = Object.freeze({
173
174
  files,
174
175
  /** Connect a changing list of remote MCP servers, reporting unavailable ones independently. */
175
176
  mcpServers,
177
+ /** Connect the open App's live Whistle commands to each Agent Session. */
178
+ ui,
176
179
  });
177
180
  export { McpServerUnavailableError, } from "./mcp.js";
178
- export { FilesEditError, filesEditFailureSchema, edit, readInputSchema, readOutputSchema, listInputSchema, listOutputSchema, globInputSchema, globOutputSchema, grepInputSchema, grepOutputSchema, editInputSchema, editOutputSchema, patchInputSchema, patchOutputSchema, } from "./files.js";
181
+ export { FilesEditError, filesEditFailureSchema, edit, readInputSchema, readOutputSchema, listInputSchema, globInputSchema, grepInputSchema, searchOutputSchema, readGuestOutputSchema, presentReadForGuest, editInputSchema, editOutputSchema, patchInputSchema, patchOutputSchema, } from "./files.js";
@@ -0,0 +1,44 @@
1
+ import type { WhistleRuntime } from "@fungi.computer/whistle";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Scope from "effect/Scope";
4
+ import { type BrowserRequestEnvelope, type BrowserResultEnvelope } from "./wire.js";
5
+ /** Browser-side platform failure before a correlated result is accepted. */
6
+ export declare class UiBrowserError extends Error {
7
+ constructor(message: string, options?: ErrorOptions);
8
+ }
9
+ /** One Agent Session's browser transport and the App's command runtime. */
10
+ export type BrowserConnectionOptions = Readonly<{
11
+ /** Session route that serves `/browser/{events,claims,results}`. */
12
+ baseUrl: string;
13
+ /** The App shell's Whistle runtime; its agent projection is what the Agent sees. */
14
+ whistle: Pick<WhistleRuntime, "snapshot" | "execute">;
15
+ /** Host-authorized I/O; omitted for ordinary browser Fetch and EventSource. */
16
+ transport?: Readonly<{
17
+ /** Send claims and results with the host's existing admission. */
18
+ fetch: typeof globalThis.fetch;
19
+ /** Open the event stream with that same admission and a maintained SSE client. */
20
+ openEvents: (url: string) => EventTarget & {
21
+ /** Stop this connection's event stream when its owning scope closes. */
22
+ close(): void;
23
+ };
24
+ }>;
25
+ }>;
26
+ /** A live browser connection; close it when the selected Session changes. */
27
+ export type BrowserConnection = Readonly<{
28
+ /** Settles when the connection ends; rejects with {@link UiBrowserError} on failure. */
29
+ closed: Promise<void>;
30
+ /** Stop serving the Agent's requests. */
31
+ close: () => void;
32
+ }>;
33
+ /**
34
+ * Execute one claimed browser request through Whistle's single door.
35
+ *
36
+ * Claim is the dispatch point: the browser request carries no cancellation, so
37
+ * Whistle supplies its own never-aborted signal, and cancellation after this
38
+ * function starts cannot promise to undo a side effect.
39
+ */
40
+ export declare function handleBrowserRequest(options: BrowserConnectionOptions, envelope: BrowserRequestEnvelope): Effect.Effect<Omit<BrowserResultEnvelope, "claimId">, UiBrowserError>;
41
+ /** Run one scope-owned EventSource connection and its Whistle request loop. */
42
+ export declare function runBrowserConnection(options: BrowserConnectionOptions): Effect.Effect<void, UiBrowserError, Scope.Scope>;
43
+ /** Start the named browser Promise facade over the Effect connection program. */
44
+ export declare function connectBrowser(options: BrowserConnectionOptions): BrowserConnection;
@@ -0,0 +1,171 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Scope from "effect/Scope";
3
+ import * as Stream from "effect/Stream";
4
+ import * as z from "zod";
5
+ import { BrowserClaimResultSchema, BrowserCommandRowSchema, BrowserRequestDeliverySchema, BrowserSettlementResultSchema, } from "./wire.js";
6
+ import { uiWireIds, BrowserExecutionOutcomeSchema } from "./wire.js";
7
+ /** Browser-side platform failure before a correlated result is accepted. */
8
+ export class UiBrowserError extends Error {
9
+ constructor(message, options) {
10
+ super(message, options);
11
+ this.name = "UiBrowserError";
12
+ }
13
+ }
14
+ const decodeRequestEvent = (input) => Effect.try(() => BrowserRequestDeliverySchema.parse(JSON.parse(z.string().parse(input))));
15
+ const decodeClaimResult = (input) => Effect.try(() => BrowserClaimResultSchema.parse(input));
16
+ const decodeSettlementResult = (input) => Effect.try(() => BrowserSettlementResultSchema.parse(input));
17
+ function requestEventData(event) {
18
+ return event instanceof MessageEvent
19
+ ? Effect.try(() => z.string().parse(event.data)).pipe(Effect.mapError(() => new UiBrowserError("Browser request event is invalid")))
20
+ : Effect.fail(new UiBrowserError("Browser request event is invalid"));
21
+ }
22
+ function projectCommands(whistle) {
23
+ return Effect.sync(() => BrowserCommandRowSchema.array().parse(whistle.snapshot().agent.map((row) => {
24
+ const sourceAction = row.action;
25
+ // Focus scope belongs to the local command graph, not the browser wire.
26
+ const projected = {
27
+ commandId: row.commandId,
28
+ sourceId: row.sourceId,
29
+ title: row.title,
30
+ category: row.category,
31
+ description: row.description ?? null,
32
+ order: row.order,
33
+ keybind: row.keybind ?? null,
34
+ availability: row.availability,
35
+ };
36
+ if (sourceAction === undefined)
37
+ return projected;
38
+ const action = {};
39
+ if (sourceAction.inputSchema !== undefined)
40
+ action.inputSchema = sourceAction.inputSchema;
41
+ if (sourceAction.outputSchema !== undefined)
42
+ action.outputSchema = sourceAction.outputSchema;
43
+ if (action.inputSchema === undefined &&
44
+ action.outputSchema === undefined)
45
+ return projected;
46
+ return { ...projected, action };
47
+ })));
48
+ }
49
+ function projectOutcome(outcome) {
50
+ const parsed = BrowserExecutionOutcomeSchema.safeParse(outcome);
51
+ return {
52
+ type: "execute",
53
+ outcome: parsed.success
54
+ ? parsed.data
55
+ : {
56
+ status: "failed",
57
+ error: {
58
+ code: "invalid_command_result",
59
+ message: "Command result is not valid JSON data",
60
+ },
61
+ },
62
+ };
63
+ }
64
+ /**
65
+ * Execute one claimed browser request through Whistle's single door.
66
+ *
67
+ * Claim is the dispatch point: the browser request carries no cancellation, so
68
+ * Whistle supplies its own never-aborted signal, and cancellation after this
69
+ * function starts cannot promise to undo a side effect.
70
+ */
71
+ export function handleBrowserRequest(options, envelope) {
72
+ if (envelope.request.type === "discover")
73
+ return projectCommands(options.whistle).pipe(Effect.map((commands) => ({
74
+ requestId: envelope.requestId,
75
+ result: { type: "discover", commands },
76
+ })));
77
+ const commandId = envelope.request.commandId;
78
+ const argumentsValue = "arguments" in envelope.request ? envelope.request.arguments : undefined;
79
+ return Effect.tryPromise({
80
+ try: () => options.whistle.execute(commandId, argumentsValue === undefined
81
+ ? { origin: "agent" }
82
+ : { origin: "agent", arguments: argumentsValue }),
83
+ catch: () => new UiBrowserError("Whistle execution rejected"),
84
+ }).pipe(Effect.map((outcome) => ({
85
+ requestId: envelope.requestId,
86
+ result: projectOutcome(outcome),
87
+ })));
88
+ }
89
+ function claimRequest(options, delivery) {
90
+ const attemptId = uiWireIds.attempt.generate();
91
+ const claimOnce = () => Effect.tryPromise({
92
+ try: (signal) => (options.transport?.fetch ?? globalThis.fetch)(`${options.baseUrl}/browser/claims`, {
93
+ method: "POST",
94
+ headers: { "Content-Type": "application/json" },
95
+ body: JSON.stringify({
96
+ attemptId,
97
+ connectionId: delivery.connectionId,
98
+ requestId: delivery.requestId,
99
+ }),
100
+ signal,
101
+ }),
102
+ catch: () => new UiBrowserError("Browser claim request failed"),
103
+ }).pipe(Effect.flatMap((response) => response.ok
104
+ ? Effect.tryPromise({
105
+ try: () => response.json(),
106
+ catch: () => new UiBrowserError("Browser claim response is invalid"),
107
+ })
108
+ : Effect.fail(new UiBrowserError(`Browser claim was rejected with status ${String(response.status)}`))), Effect.flatMap((input) => Effect.try(() => z.record(z.string(), z.unknown()).parse(input)).pipe(Effect.flatMap(decodeClaimResult))), Effect.mapError(() => new UiBrowserError("Browser claim response is invalid")));
109
+ const waitForClaim = () => claimOnce().pipe(Effect.flatMap((claim) => claim.status === "waiting"
110
+ ? Effect.sleep(claim.retryAfterMs).pipe(Effect.andThen(Effect.suspend(waitForClaim)))
111
+ : Effect.succeed(claim)));
112
+ return Effect.suspend(waitForClaim);
113
+ }
114
+ function postResult(options, result) {
115
+ return Effect.tryPromise({
116
+ try: (signal) => (options.transport?.fetch ?? globalThis.fetch)(`${options.baseUrl}/browser/results`, {
117
+ method: "POST",
118
+ headers: { "Content-Type": "application/json" },
119
+ body: JSON.stringify(result),
120
+ signal,
121
+ }),
122
+ catch: () => new UiBrowserError("Browser result request failed"),
123
+ }).pipe(Effect.flatMap((response) => response.ok
124
+ ? Effect.tryPromise({
125
+ try: () => response.json(),
126
+ catch: () => new UiBrowserError("Browser settlement response is invalid"),
127
+ }).pipe(Effect.flatMap((input) => Effect.try(() => z.record(z.string(), z.unknown()).parse(input)).pipe(Effect.flatMap(decodeSettlementResult))), Effect.mapError(() => new UiBrowserError("Browser settlement response is invalid")), Effect.asVoid)
128
+ : Effect.fail(new UiBrowserError(`Browser result was rejected with status ${String(response.status)}`))));
129
+ }
130
+ function handleRequestEvent(options, event) {
131
+ return requestEventData(event).pipe(Effect.flatMap(decodeRequestEvent), Effect.mapError(() => new UiBrowserError("Browser request payload is invalid")), Effect.flatMap((delivery) => claimRequest(options, delivery).pipe(Effect.flatMap((claim) => claim.status !== "claimed"
132
+ ? Effect.void
133
+ : handleBrowserRequest(options, delivery).pipe(Effect.flatMap((result) => postResult(options, {
134
+ ...result,
135
+ claimId: claim.claimId,
136
+ })))))));
137
+ }
138
+ /** Run one scope-owned EventSource connection and its Whistle request loop. */
139
+ export function runBrowserConnection(options) {
140
+ return Effect.acquireRelease(Effect.try({
141
+ try: () => {
142
+ const url = `${options.baseUrl}/browser/events`;
143
+ return options.transport === undefined
144
+ ? new EventSource(url)
145
+ : options.transport.openEvents(url);
146
+ },
147
+ catch: () => new UiBrowserError("Browser event stream could not open"),
148
+ }), (source) => Effect.sync(() => {
149
+ source.close();
150
+ })).pipe(Effect.flatMap((source) => Stream.fromEventListener(source, "browser-request", {
151
+ bufferSize: 1,
152
+ }).pipe(Stream.mapEffect((event) => handleRequestEvent(options, event)), Stream.runDrain)));
153
+ }
154
+ /** Start the named browser Promise facade over the Effect connection program. */
155
+ export function connectBrowser(options) {
156
+ const cancellation = new AbortController();
157
+ const closed = Effect.runPromise(Effect.scoped(runBrowserConnection(options)), {
158
+ signal: cancellation.signal,
159
+ }).catch((cause) => {
160
+ if (!cancellation.signal.aborted)
161
+ throw new UiBrowserError("Browser connection failed", {
162
+ cause,
163
+ });
164
+ });
165
+ return Object.freeze({
166
+ closed,
167
+ close: () => {
168
+ cancellation.abort();
169
+ },
170
+ });
171
+ }