@fungi.computer/agar 0.0.0-stage → 0.0.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 (159) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +766 -2
  3. package/dist/agent-message-meta.d.ts +74 -0
  4. package/dist/agent-message-meta.js +63 -0
  5. package/dist/agents.d.ts +170 -0
  6. package/dist/agents.js +218 -0
  7. package/dist/app-build-contract.d.ts +43 -0
  8. package/dist/app-build-contract.js +24 -0
  9. package/dist/apps.d.ts +66 -0
  10. package/dist/apps.js +47 -0
  11. package/dist/backend-declaration.d.ts +6 -0
  12. package/dist/backend-declaration.js +65 -0
  13. package/dist/backend-definition.d.ts +88 -0
  14. package/dist/backend-definition.js +158 -0
  15. package/dist/backend-refusal.d.ts +10 -0
  16. package/dist/backend-refusal.js +10 -0
  17. package/dist/backend.d.ts +30 -0
  18. package/dist/backend.js +28 -0
  19. package/dist/capabilities.d.ts +30 -0
  20. package/dist/capabilities.js +50 -0
  21. package/dist/channels.d.ts +142 -0
  22. package/dist/channels.js +97 -0
  23. package/dist/commands.d.ts +33 -0
  24. package/dist/commands.js +46 -0
  25. package/dist/computer-contracts.d.ts +73 -0
  26. package/dist/computer-contracts.js +57 -0
  27. package/dist/computer.d.ts +294 -0
  28. package/dist/computer.js +319 -0
  29. package/dist/connect.d.ts +11 -0
  30. package/dist/connect.js +211 -0
  31. package/dist/errors.d.ts +34 -0
  32. package/dist/errors.js +106 -0
  33. package/dist/failure.d.ts +2 -0
  34. package/dist/failure.js +9 -0
  35. package/dist/files.d.ts +25 -0
  36. package/dist/files.js +84 -0
  37. package/dist/game-servers.d.ts +530 -0
  38. package/dist/game-servers.js +244 -0
  39. package/dist/gills.d.ts +43 -0
  40. package/dist/gills.js +174 -0
  41. package/dist/git-commit.d.ts +143 -0
  42. package/dist/git-commit.js +251 -0
  43. package/dist/git-rpc.d.ts +310 -0
  44. package/dist/git-rpc.js +166 -0
  45. package/dist/git-steps.d.ts +579 -0
  46. package/dist/git-steps.js +804 -0
  47. package/dist/git-wire.d.ts +33 -0
  48. package/dist/git-wire.js +240 -0
  49. package/dist/git.d.ts +322 -0
  50. package/dist/git.js +98 -0
  51. package/dist/host.d.ts +97 -0
  52. package/dist/host.js +732 -0
  53. package/dist/inbox.d.ts +99 -0
  54. package/dist/inbox.js +184 -0
  55. package/dist/index.d.ts +31 -0
  56. package/dist/index.js +15 -0
  57. package/dist/inference.d.ts +17 -0
  58. package/dist/inference.js +115 -0
  59. package/dist/internal/app-error.d.ts +8 -0
  60. package/dist/internal/app-error.js +36 -0
  61. package/dist/internal/app-session.d.ts +8 -0
  62. package/dist/internal/app-session.js +551 -0
  63. package/dist/internal/back-inputs.d.ts +22 -0
  64. package/dist/internal/back-inputs.js +93 -0
  65. package/dist/internal/browser-bridge.d.ts +30 -0
  66. package/dist/internal/browser-bridge.js +203 -0
  67. package/dist/internal/capabilities.d.ts +13 -0
  68. package/dist/internal/capabilities.js +138 -0
  69. package/dist/internal/commands.d.ts +9 -0
  70. package/dist/internal/commands.js +257 -0
  71. package/dist/internal/disconnect-host.d.ts +7 -0
  72. package/dist/internal/disconnect-host.js +19 -0
  73. package/dist/internal/git-edit-tree.d.ts +6 -0
  74. package/dist/internal/git-edit-tree.js +46 -0
  75. package/dist/internal/memory-fs.d.ts +39 -0
  76. package/dist/internal/memory-fs.js +154 -0
  77. package/dist/internal/protocol.d.ts +116 -0
  78. package/dist/internal/protocol.js +6 -0
  79. package/dist/internal/query-feed.d.ts +23 -0
  80. package/dist/internal/query-feed.js +111 -0
  81. package/dist/internal/result.d.ts +22 -0
  82. package/dist/internal/result.js +61 -0
  83. package/dist/internal/rpc-contract.d.ts +24 -0
  84. package/dist/internal/rpc-contract.js +78 -0
  85. package/dist/internal/schemas.d.ts +307 -0
  86. package/dist/internal/schemas.js +524 -0
  87. package/dist/internal/store.d.ts +8 -0
  88. package/dist/internal/store.js +30 -0
  89. package/dist/internal/terminal-gate.d.ts +17 -0
  90. package/dist/internal/terminal-gate.js +94 -0
  91. package/dist/lifecycle.d.ts +44 -0
  92. package/dist/lifecycle.js +1 -0
  93. package/dist/marketplace.d.ts +159 -0
  94. package/dist/marketplace.js +102 -0
  95. package/dist/media-json.d.ts +181 -0
  96. package/dist/media-json.js +204 -0
  97. package/dist/media-library.d.ts +11 -0
  98. package/dist/media-library.js +15 -0
  99. package/dist/media-request-id.d.ts +7 -0
  100. package/dist/media-request-id.js +16 -0
  101. package/dist/media.d.ts +354 -0
  102. package/dist/media.js +654 -0
  103. package/dist/method-refusal.d.ts +64 -0
  104. package/dist/method-refusal.js +48 -0
  105. package/dist/pending.d.ts +10 -0
  106. package/dist/pending.js +10 -0
  107. package/dist/query.d.ts +76 -0
  108. package/dist/query.js +195 -0
  109. package/dist/react-mount.d.ts +23 -0
  110. package/dist/react-mount.js +108 -0
  111. package/dist/react.d.ts +41 -0
  112. package/dist/react.js +111 -0
  113. package/dist/read-copy.d.ts +103 -0
  114. package/dist/read-copy.js +195 -0
  115. package/dist/roles/files.d.ts +295 -0
  116. package/dist/roles/files.js +184 -0
  117. package/dist/root.d.ts +21 -0
  118. package/dist/root.js +1 -0
  119. package/dist/rpc.d.ts +1 -0
  120. package/dist/rpc.js +1 -0
  121. package/dist/status.d.ts +17 -0
  122. package/dist/status.js +50 -0
  123. package/dist/storage-wire.d.ts +167 -0
  124. package/dist/storage-wire.js +115 -0
  125. package/dist/storage.d.ts +72 -0
  126. package/dist/storage.js +143 -0
  127. package/dist/store.d.ts +11 -0
  128. package/dist/store.js +1 -0
  129. package/dist/test-kit/git.d.ts +31 -0
  130. package/dist/test-kit/git.js +174 -0
  131. package/dist/ui.d.ts +169 -0
  132. package/dist/ui.js +10 -0
  133. package/dist/window.d.ts +41 -0
  134. package/dist/window.js +15 -0
  135. package/dist/world.d.ts +16 -0
  136. package/dist/world.js +17 -0
  137. package/docs/api/README.md +28 -0
  138. package/docs/api/app-build-contract.md +113 -0
  139. package/docs/api/backend-declaration.md +62 -0
  140. package/docs/api/backend-definition.md +215 -0
  141. package/docs/api/backend.md +269 -0
  142. package/docs/api/errors.md +169 -0
  143. package/docs/api/files.md +91 -0
  144. package/docs/api/gills.md +164 -0
  145. package/docs/api/git-commit.md +279 -0
  146. package/docs/api/git-rpc.md +243 -0
  147. package/docs/api/git-steps.md +1010 -0
  148. package/docs/api/git-wire.md +197 -0
  149. package/docs/api/git.md +579 -0
  150. package/docs/api/index.md +2880 -0
  151. package/docs/api/media-json.md +507 -0
  152. package/docs/api/media-request-id.md +32 -0
  153. package/docs/api/modules.md +32 -0
  154. package/docs/api/query.md +253 -0
  155. package/docs/api/react.md +216 -0
  156. package/docs/api/read-copy.md +204 -0
  157. package/docs/api/rpc.md +128 -0
  158. package/docs/api/storage.md +620 -0
  159. package/package.json +197 -3
@@ -0,0 +1,551 @@
1
+ import * as Schema from "effect/Schema";
2
+ /**
3
+ * WHY: one session owns stores, callbacks, transport, and terminal teardown;
4
+ * versioned host facts are applied only at this seam (2026-08-27).
5
+ */
6
+ import { newMessagePortRpcSession, RpcStub, RpcTarget, } from "@fungi.computer/capnweb";
7
+ import { statusActionAllowed } from "../status.js";
8
+ import { AppError } from "../errors.js";
9
+ import { reportCatchFailure } from "../failure.js";
10
+ import { createLocalBindingState, createReservedChordState, installBrowserBridge, } from "./browser-bridge.js";
11
+ import { installBackInputs, macHistoryKeys } from "./back-inputs.js";
12
+ import { createCapabilityDirectory, disposeRpcValue, } from "./capabilities.js";
13
+ import { createAppCommands } from "./commands.js";
14
+ import { APP_PROTOCOL_VERSION, } from "./protocol.js";
15
+ import { callRpc, rpcFailure, rpcSuccess, unwrapRpcResult } from "./result.js";
16
+ import { parseBrowserAction, parseBrowserActionOutcomeResult, parseBootstrapResult, parseHostReconfiguration, parseContext, parseErrorData, parseFact, parseAgentSessionSelection, parseIdentity, parseLifecycle, parseNavigation, parseStatusAction, parseStatusItems, parseNavigationStateInput, parsePermissionOutcomeResult, parsePermissionRequest, parseReservedChords, parseTheme, parseToast, parseVoidResult, } from "./schemas.js";
17
+ const rpcStubSchema = Schema.declare((value) => value instanceof RpcStub);
18
+ import { createReadonlyStore } from "./store.js";
19
+ class AppClientTarget extends RpcTarget {
20
+ #disposed = false;
21
+ #state;
22
+ constructor(state) {
23
+ super();
24
+ this.#state = state;
25
+ }
26
+ disconnect(error) {
27
+ try {
28
+ if (this.#disposed)
29
+ throw disconnected();
30
+ const cause = error === undefined ? undefined : parseErrorData(error);
31
+ // The RPC result is the graceful-disconnect acknowledgement. Closing the
32
+ // port on a later turn lets Cap'n Web send that acknowledgement first.
33
+ setTimeout(() => this.#state.disconnect?.(cause ?? gracefulDisconnect()), 0);
34
+ return Promise.resolve(rpcSuccess(undefined));
35
+ }
36
+ catch (cause) {
37
+ return Promise.resolve(rpcFailure(cause));
38
+ }
39
+ }
40
+ async requestClose() {
41
+ try {
42
+ if (this.#disposed)
43
+ throw disconnected();
44
+ return rpcSuccess(await (this.#state.beforeClose?.() ?? true));
45
+ }
46
+ catch (cause) {
47
+ return rpcFailure(cause);
48
+ }
49
+ }
50
+ async requestBack() {
51
+ try {
52
+ if (this.#disposed)
53
+ throw disconnected();
54
+ return rpcSuccess(await (this.#state.back?.() ?? false));
55
+ }
56
+ catch (cause) {
57
+ return rpcFailure(cause);
58
+ }
59
+ }
60
+ async activateStatus(value) {
61
+ try {
62
+ if (this.#disposed)
63
+ throw disconnected();
64
+ const action = parseStatusAction(value);
65
+ // A shell's stale copy can still send an item the App has since
66
+ // dropped or disabled; only what it declares now reaches the App.
67
+ if (!statusActionAllowed(this.#state.statusItems, action))
68
+ return rpcSuccess(false);
69
+ return rpcSuccess(await (this.#state.statusAction?.(action) ?? false));
70
+ }
71
+ catch (cause) {
72
+ return rpcFailure(cause);
73
+ }
74
+ }
75
+ [Symbol.dispose]() {
76
+ this.#disposed = true;
77
+ }
78
+ async updateContext(value) {
79
+ if (this.#disposed)
80
+ return rpcFailure(disconnected());
81
+ return this.#update(value, "context", parseContext, (next) => {
82
+ this.#state.context.update(next);
83
+ });
84
+ }
85
+ reconfigure(value) {
86
+ let retained = [];
87
+ try {
88
+ if (this.#disposed)
89
+ throw disconnected();
90
+ const next = parseHostReconfiguration(value);
91
+ if (next.context.version <= this.#state.versions.context) {
92
+ return Promise.resolve(rpcSuccess(undefined));
93
+ }
94
+ // RPC argument handles are disposed when this call returns. The directory
95
+ // owns duplicates until the next replacement or App disposal.
96
+ for (const grant of next.capabilities)
97
+ retained.push({
98
+ ...grant,
99
+ handle: Schema.decodeUnknownSync(rpcStubSchema)(grant.handle).dup(),
100
+ });
101
+ if (!this.#state.capabilities)
102
+ throw disconnected();
103
+ this.#state.capabilities.replace(retained);
104
+ retained = [];
105
+ this.#state.versions.context = next.context.version;
106
+ this.#state.context.update(next.context.value);
107
+ return Promise.resolve(rpcSuccess(undefined));
108
+ }
109
+ catch (cause) {
110
+ for (const grant of retained)
111
+ disposeRpcValue(grant.handle);
112
+ return Promise.resolve(rpcFailure(cause));
113
+ }
114
+ }
115
+ updateLifecycle(value) {
116
+ try {
117
+ if (this.#disposed)
118
+ throw disconnected();
119
+ const fact = parseFact(parseLifecycle)(value);
120
+ if (fact.version <= this.#state.versions.lifecycle)
121
+ return Promise.resolve(rpcSuccess(undefined));
122
+ const current = this.#state.lifecycle.get();
123
+ if (!isHostLifecycleTransition(current, fact.value)) {
124
+ throw new AppError("The host sent an invalid lifecycle transition", {
125
+ code: "PROTOCOL",
126
+ retryable: false,
127
+ });
128
+ }
129
+ this.#state.versions.lifecycle = fact.version;
130
+ this.#state.lifecycle.update(fact.value);
131
+ return Promise.resolve(rpcSuccess(undefined));
132
+ }
133
+ catch (cause) {
134
+ return Promise.resolve(rpcFailure(cause));
135
+ }
136
+ }
137
+ async updateReservedChords(value) {
138
+ if (this.#disposed)
139
+ return rpcFailure(disconnected());
140
+ return this.#update(value, "chords", parseReservedChords, (next) => {
141
+ this.#state.chords.update(next);
142
+ });
143
+ }
144
+ async updateTheme(value) {
145
+ if (this.#disposed)
146
+ return rpcFailure(disconnected());
147
+ return this.#update(value, "theme", parseTheme, (next) => {
148
+ this.#state.theme.update(next);
149
+ });
150
+ }
151
+ #update(value, key, parse, apply) {
152
+ try {
153
+ const fact = parseFact(parse)(value);
154
+ if (fact.version > this.#state.versions[key]) {
155
+ this.#state.versions[key] = fact.version;
156
+ apply(fact.value);
157
+ }
158
+ return Promise.resolve(rpcSuccess(undefined));
159
+ }
160
+ catch (cause) {
161
+ return Promise.resolve(rpcFailure(cause));
162
+ }
163
+ }
164
+ }
165
+ export async function connectAppPort(options) {
166
+ const state = createClientState();
167
+ const target = new AppClientTarget(state);
168
+ const remote = newMessagePortRpcSession(options.port, target);
169
+ let bootstrapOwner;
170
+ const brokenPromise = createBrokenPromise(remote);
171
+ try {
172
+ performance.mark("fungi.app-open.agar-rpc-connect-start");
173
+ const result = await Promise.race([
174
+ remote.connect({
175
+ epoch: options.epoch,
176
+ protocolVersion: APP_PROTOCOL_VERSION,
177
+ }),
178
+ brokenPromise,
179
+ ]);
180
+ performance.mark("fungi.app-open.agar-rpc-connect-ready");
181
+ bootstrapOwner = result;
182
+ const bootstrap = unwrapRpcResult(parseBootstrapResult(result));
183
+ if (!options.isActive())
184
+ throw cancelled();
185
+ const root = createRoot({
186
+ bootstrap,
187
+ bootstrapOwner,
188
+ options,
189
+ remote,
190
+ state,
191
+ target,
192
+ });
193
+ performance.mark("fungi.app-open.agar-root-created");
194
+ bootstrapOwner = undefined;
195
+ return root;
196
+ }
197
+ catch (cause) {
198
+ return rejectConnection(cause, options, target, remote, bootstrapOwner);
199
+ }
200
+ }
201
+ function applyBootstrapFacts(state, facts) {
202
+ state.context.update(facts.context.value);
203
+ state.theme.update(facts.theme.value);
204
+ state.lifecycle.update(facts.lifecycle.value);
205
+ state.chords.update(facts.chords.value);
206
+ state.versions.context = facts.context.version;
207
+ state.versions.theme = facts.theme.version;
208
+ state.versions.lifecycle = facts.lifecycle.version;
209
+ state.versions.chords = facts.chords.version;
210
+ }
211
+ function cancelled() {
212
+ return new AppError("App connection was cancelled", {
213
+ code: "CANCELLED",
214
+ retryable: false,
215
+ });
216
+ }
217
+ function createBrokenPromise(remote) {
218
+ let broken;
219
+ const promise = new Promise((_, reject) => {
220
+ broken = reject;
221
+ });
222
+ promise.catch((cause) => {
223
+ reportCatchFailure("app-session.rpc-disconnect", cause);
224
+ });
225
+ remote.onRpcBroken(() => broken?.(new AppError("The app host disconnected", {
226
+ code: "DISCONNECTED",
227
+ retryable: true,
228
+ })));
229
+ return promise;
230
+ }
231
+ function createClientState() {
232
+ return {
233
+ statusItems: [],
234
+ bindings: createLocalBindingState(),
235
+ chords: createReservedChordState([]),
236
+ context: createReadonlyStore({
237
+ computerId: null,
238
+ focus: { editing: false, surface: null },
239
+ openRequest: null,
240
+ visibility: "hidden",
241
+ }),
242
+ lifecycle: createReadonlyStore("connecting"),
243
+ theme: createReadonlyStore("default"),
244
+ canGoBack: false,
245
+ versions: { chords: -1, context: -1, lifecycle: -1, theme: -1 },
246
+ };
247
+ }
248
+ function createRoot(options) {
249
+ const identity = parseIdentity(options.bootstrap.identity);
250
+ const facts = parseBootstrapFacts(options.bootstrap);
251
+ applyBootstrapFacts(options.state, facts);
252
+ const capabilities = createCapabilityDirectory(options.bootstrap.capabilities);
253
+ options.state.capabilities = capabilities;
254
+ return createRootRuntime(options, identity, capabilities);
255
+ }
256
+ function createRootBridge(options, identity, dispose) {
257
+ return installBrowserBridge({
258
+ bindings: options.state.bindings,
259
+ chords: options.state.chords,
260
+ identity,
261
+ onBroken: (cause) => {
262
+ dispose({
263
+ code: "DISCONNECTED",
264
+ message: cause instanceof AppError
265
+ ? cause.message
266
+ : "The app host rejected a browser bridge operation",
267
+ retryable: true,
268
+ });
269
+ },
270
+ remote: options.remote,
271
+ window: options.options.window,
272
+ });
273
+ }
274
+ function createRootRuntime(options, identity, capabilities) {
275
+ let disposed = false;
276
+ const dispose = (error) => {
277
+ if (disposed)
278
+ return;
279
+ disposed = true;
280
+ exhaustCleanup([
281
+ () => {
282
+ if (error)
283
+ options.state.lifecycle.update("disconnected");
284
+ },
285
+ () => {
286
+ options.state.lifecycle.update("disposing");
287
+ },
288
+ removeBrowserBridge,
289
+ removeBackInputs,
290
+ () => {
291
+ commands.dispose();
292
+ },
293
+ () => {
294
+ options.state.beforeClose = undefined;
295
+ options.state.back = undefined;
296
+ options.state.statusAction = undefined;
297
+ },
298
+ () => {
299
+ capabilities.disconnect();
300
+ },
301
+ () => {
302
+ options.target[Symbol.dispose]();
303
+ },
304
+ () => {
305
+ disposeRpcValue(options.bootstrapOwner);
306
+ },
307
+ () => {
308
+ disposeRpcValue(options.remote);
309
+ },
310
+ () => {
311
+ options.options.port.close();
312
+ },
313
+ () => {
314
+ options.state.lifecycle.update("disposed");
315
+ },
316
+ options.options.onDisposed,
317
+ ], "app connection disposal");
318
+ };
319
+ const assertConnected = () => {
320
+ if (disposed)
321
+ throw new AppError("The app connection is disposed", {
322
+ code: "DISCONNECTED",
323
+ retryable: true,
324
+ });
325
+ };
326
+ const commands = createAppCommands(options.remote, assertConnected, () => {
327
+ dispose({
328
+ code: "DISCONNECTED",
329
+ message: "The app host rejected a terminal command operation",
330
+ retryable: true,
331
+ });
332
+ }, options.state.bindings);
333
+ const ui = createRootUi(options.remote, assertConnected, options.state);
334
+ const appWindow = createAppWindow(options.state, options.remote, assertConnected);
335
+ const removeBrowserBridge = createRootBridge(options, identity, dispose);
336
+ const removeBackInputs = installBackInputs({
337
+ window: options.options.window,
338
+ mac: macHistoryKeys(),
339
+ canGoBack: () => options.state.canGoBack && options.state.back !== undefined,
340
+ back: () => {
341
+ const back = options.state.back;
342
+ if (!back)
343
+ return;
344
+ void Promise.resolve()
345
+ .then(back)
346
+ .catch((cause) => {
347
+ reportCatchFailure("app-window.back-input", cause);
348
+ });
349
+ },
350
+ });
351
+ options.state.disconnect = dispose;
352
+ options.remote.onRpcBroken(() => {
353
+ disposeRemoteSession(dispose);
354
+ });
355
+ return exposeRoot({
356
+ capabilities: capabilities.directory,
357
+ commands: commands.commands,
358
+ context: options.state.context,
359
+ dispose,
360
+ identity,
361
+ lifecycle: options.state.lifecycle,
362
+ theme: options.state.theme,
363
+ ui,
364
+ window: appWindow,
365
+ });
366
+ }
367
+ function createAppWindow(state, remote, assertConnected) {
368
+ const navigation = Object.freeze({
369
+ async publish(input) {
370
+ assertConnected();
371
+ const wire = parseNavigationStateInput(input);
372
+ state.canGoBack = wire.canGoBack;
373
+ await callRpc(remote.reportNavigation(wire), parseVoidResult);
374
+ },
375
+ onBack(handler) {
376
+ assertConnected();
377
+ if (state.back !== undefined)
378
+ throw new AppError("The App already owns Back requests", {
379
+ code: "INVALID_ARGUMENT",
380
+ retryable: false,
381
+ });
382
+ state.back = handler;
383
+ let released = false;
384
+ return () => {
385
+ if (released)
386
+ return;
387
+ released = true;
388
+ if (state.back === handler)
389
+ state.back = undefined;
390
+ };
391
+ },
392
+ });
393
+ return Object.freeze({
394
+ navigation,
395
+ onBeforeClose(handler) {
396
+ assertConnected();
397
+ if (state.beforeClose !== undefined)
398
+ throw new AppError("The App already owns a close consultation", {
399
+ code: "INVALID_ARGUMENT",
400
+ retryable: false,
401
+ });
402
+ state.beforeClose = handler;
403
+ let released = false;
404
+ return () => {
405
+ if (released)
406
+ return;
407
+ released = true;
408
+ if (state.beforeClose === handler)
409
+ state.beforeClose = undefined;
410
+ };
411
+ },
412
+ });
413
+ }
414
+ function createRootUi(remote, assertConnected, state) {
415
+ return Object.freeze({
416
+ async requestComputerChoice() {
417
+ assertConnected();
418
+ await callRpc(remote.requestComputerChoice(), parseVoidResult);
419
+ },
420
+ async requestBrowserAction(input) {
421
+ assertConnected();
422
+ const parsed = parseBrowserAction(input);
423
+ if (parsed.kind === "refused")
424
+ return parsed;
425
+ return await callRpc(remote.requestBrowserAction(parsed), parseBrowserActionOutcomeResult);
426
+ },
427
+ onStatusAction(handler) {
428
+ assertConnected();
429
+ if (state.statusAction !== undefined)
430
+ throw new AppError("The App already owns its status actions", {
431
+ code: "INVALID_ARGUMENT",
432
+ retryable: false,
433
+ });
434
+ state.statusAction = handler;
435
+ let released = false;
436
+ return () => {
437
+ if (released)
438
+ return;
439
+ released = true;
440
+ if (state.statusAction === handler)
441
+ state.statusAction = undefined;
442
+ };
443
+ },
444
+ async requestAppPermissions(input) {
445
+ assertConnected();
446
+ return await callRpc(remote.requestAppPermissions(parsePermissionRequest(input)), parsePermissionOutcomeResult);
447
+ },
448
+ async openDesktopSettings() {
449
+ assertConnected();
450
+ await callRpc(remote.openDesktopSettings(), parseVoidResult);
451
+ },
452
+ async openTeamBilling() {
453
+ assertConnected();
454
+ await callRpc(remote.openTeamBilling(), parseVoidResult);
455
+ },
456
+ async openNavigation(input) {
457
+ assertConnected();
458
+ await callRpc(remote.openNavigation(parseNavigation(input)), parseVoidResult);
459
+ },
460
+ async reportStatus(items) {
461
+ assertConnected();
462
+ const parsed = parseStatusItems(items);
463
+ // Actions are checked against this as soon as the App declares it.
464
+ state.statusItems = parsed;
465
+ await callRpc(remote.reportStatus(parsed), parseVoidResult);
466
+ },
467
+ async reportAgentSessionSelection(selection) {
468
+ assertConnected();
469
+ await callRpc(remote.reportAgentSessionSelection(parseAgentSessionSelection(selection)), parseVoidResult);
470
+ },
471
+ async showToast(input) {
472
+ assertConnected();
473
+ await callRpc(remote.showToast(parseToast(input)), parseVoidResult);
474
+ },
475
+ });
476
+ }
477
+ function disconnected() {
478
+ return new AppError("The app connection is disposed", {
479
+ code: "DISCONNECTED",
480
+ retryable: true,
481
+ });
482
+ }
483
+ function disposeRemoteSession(dispose) {
484
+ dispose({
485
+ code: "DISCONNECTED",
486
+ message: "The app host disconnected",
487
+ retryable: true,
488
+ });
489
+ }
490
+ function exhaustCleanup(cleanups, label) {
491
+ for (const cleanup of cleanups) {
492
+ try {
493
+ cleanup();
494
+ }
495
+ catch (cause) {
496
+ console.warn(`${label} cleanup failed`, cause);
497
+ }
498
+ }
499
+ }
500
+ function exposeRoot(value) {
501
+ return Object.freeze(value);
502
+ }
503
+ function gracefulDisconnect() {
504
+ return {
505
+ code: "DISCONNECTED",
506
+ message: "The app host disconnected",
507
+ retryable: false,
508
+ };
509
+ }
510
+ function isHostLifecycleTransition(current, next) {
511
+ return (current === next ||
512
+ (current === "connected" && next === "suspended") ||
513
+ (current === "suspended" && next === "connected"));
514
+ }
515
+ function parseBootstrapFacts(bootstrap) {
516
+ const context = parseFact(parseContext)(bootstrap.context);
517
+ const theme = parseFact(parseTheme)(bootstrap.theme);
518
+ const lifecycle = parseFact(parseLifecycle)(bootstrap.lifecycle);
519
+ const chords = parseFact(parseReservedChords)(bootstrap.reservedChords);
520
+ if (lifecycle.value !== "connected" && lifecycle.value !== "suspended")
521
+ throw new AppError("The bootstrap lifecycle is invalid", {
522
+ code: "PROTOCOL",
523
+ retryable: false,
524
+ });
525
+ return { chords, context, lifecycle, theme };
526
+ }
527
+ function rejectConnection(cause, options, target, remote, bootstrapOwner) {
528
+ exhaustCleanup([
529
+ () => {
530
+ target[Symbol.dispose]();
531
+ },
532
+ () => {
533
+ if (bootstrapOwner)
534
+ disposeRpcValue(bootstrapOwner);
535
+ },
536
+ () => {
537
+ disposeRpcValue(remote);
538
+ },
539
+ () => {
540
+ options.port.close();
541
+ },
542
+ options.onDisposed,
543
+ ], "app connection rejection");
544
+ throw cause instanceof AppError
545
+ ? cause
546
+ : new AppError("Unable to establish the app RPC session", {
547
+ cause,
548
+ code: "PROTOCOL",
549
+ retryable: false,
550
+ });
551
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * WHY: an App iframe shares the tab's history with the Hub page, so the
3
+ * browser's own back inputs pressed inside an App would navigate the whole
4
+ * workspace away. They mean App Back here, or nothing (Levi, 2026-10-07).
5
+ */
6
+ export type BackInputOptions = Readonly<{
7
+ window: Pick<Window, "addEventListener" | "removeEventListener">;
8
+ /** macOS uses ⌘[ and ⌘←; other platforms use Alt+←. */
9
+ mac: boolean;
10
+ /** Whether the App last reported it can go back. */
11
+ canGoBack: () => boolean;
12
+ /** Ask the App's own Back handler to move. */
13
+ back: () => void;
14
+ }>;
15
+ /**
16
+ * Turn the browser's back keys and mouse back button into App Back while the
17
+ * App can go back, and keep every back or forward input from leaving the
18
+ * page. Input the App already handled (`defaultPrevented`) is left alone.
19
+ */
20
+ export declare function installBackInputs(options: BackInputOptions): () => void;
21
+ /** Whether this browser uses the macOS history keys. */
22
+ export declare function macHistoryKeys(): boolean;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * WHY: an App iframe shares the tab's history with the Hub page, so the
3
+ * browser's own back inputs pressed inside an App would navigate the whole
4
+ * workspace away. They mean App Back here, or nothing (Levi, 2026-10-07).
5
+ */
6
+ import * as Predicate from "effect/Predicate";
7
+ /**
8
+ * Turn the browser's back keys and mouse back button into App Back while the
9
+ * App can go back, and keep every back or forward input from leaving the
10
+ * page. Input the App already handled (`defaultPrevented`) is left alone.
11
+ */
12
+ export function installBackInputs(options) {
13
+ const onKeyDown = (event) => {
14
+ if (event.defaultPrevented || event.isComposing)
15
+ return;
16
+ const direction = keyDirection(event, options.mac);
17
+ if (direction === null)
18
+ return;
19
+ event.preventDefault();
20
+ if (direction === "back" && !event.repeat && options.canGoBack())
21
+ options.back();
22
+ };
23
+ const onMouse = (event) => {
24
+ const direction = mouseDirection(event.button);
25
+ if (direction === null)
26
+ return;
27
+ const handled = event.defaultPrevented;
28
+ event.preventDefault();
29
+ if (event.type === "mouseup" &&
30
+ direction === "back" &&
31
+ !handled &&
32
+ options.canGoBack())
33
+ options.back();
34
+ };
35
+ options.window.addEventListener("keydown", onKeyDown);
36
+ for (const type of mouseEvents)
37
+ options.window.addEventListener(type, onMouse);
38
+ return () => {
39
+ options.window.removeEventListener("keydown", onKeyDown);
40
+ for (const type of mouseEvents)
41
+ options.window.removeEventListener(type, onMouse);
42
+ };
43
+ }
44
+ /** Browsers navigate on one of these; all are cancelled, Back runs once. */
45
+ const mouseEvents = ["mousedown", "mouseup", "auxclick"];
46
+ function mouseDirection(button) {
47
+ if (button === 3)
48
+ return "back";
49
+ if (button === 4)
50
+ return "forward";
51
+ return null;
52
+ }
53
+ function keyDirection(event, mac) {
54
+ if (event.shiftKey || event.ctrlKey)
55
+ return null;
56
+ if (mac) {
57
+ if (!event.metaKey || event.altKey)
58
+ return null;
59
+ if (event.key === "[")
60
+ return "back";
61
+ if (event.key === "]")
62
+ return "forward";
63
+ // ⌘← and ⌘→ move the caret in text; only elsewhere are they history keys.
64
+ if (editable(event.target))
65
+ return null;
66
+ if (event.key === "ArrowLeft")
67
+ return "back";
68
+ if (event.key === "ArrowRight")
69
+ return "forward";
70
+ return null;
71
+ }
72
+ if (!event.altKey || event.metaKey)
73
+ return null;
74
+ if (event.key === "ArrowLeft")
75
+ return "back";
76
+ if (event.key === "ArrowRight")
77
+ return "forward";
78
+ return null;
79
+ }
80
+ // Only a DOM keyboard event reaches this, so the DOM classes exist.
81
+ function editable(target) {
82
+ if (!(target instanceof HTMLElement))
83
+ return false;
84
+ return (target.isContentEditable ||
85
+ target instanceof HTMLInputElement ||
86
+ target instanceof HTMLTextAreaElement ||
87
+ target instanceof HTMLSelectElement);
88
+ }
89
+ /** Whether this browser uses the macOS history keys. */
90
+ export function macHistoryKeys() {
91
+ return (!Predicate.isUndefined(globalThis.navigator) &&
92
+ /Mac|iPhone|iPad/u.test(globalThis.navigator.userAgent));
93
+ }
@@ -0,0 +1,30 @@
1
+ import type { AppIdentity } from "../lifecycle.js";
2
+ import type { AppHostRpc, ReservedChord } from "./protocol.js";
3
+ export type ReservedChordState = {
4
+ get(): readonly Readonly<ReservedChord>[];
5
+ subscribe(listener: () => void): () => void;
6
+ update(value: readonly ReservedChord[]): void;
7
+ };
8
+ /** One of the App's own command bindings, run locally inside the iframe. */
9
+ export type LocalBinding = Readonly<{
10
+ /** Keymap binding string, e.g. `mod+s`. */
11
+ key: string;
12
+ /** Run the command's own handler with keyboard origin. */
13
+ run: () => void;
14
+ }>;
15
+ /** The App's current single-chord command bindings. */
16
+ export type LocalBindingState = {
17
+ get(): readonly LocalBinding[];
18
+ subscribe(listener: () => void): () => void;
19
+ update(value: readonly LocalBinding[]): void;
20
+ };
21
+ export declare function createLocalBindingState(): LocalBindingState;
22
+ export declare function createReservedChordState(initial: readonly ReservedChord[]): ReservedChordState;
23
+ export declare function installBrowserBridge(options: {
24
+ readonly bindings?: LocalBindingState;
25
+ readonly chords: ReservedChordState;
26
+ readonly identity: AppIdentity;
27
+ readonly onBroken: (cause?: unknown) => void;
28
+ readonly remote: Pick<AppHostRpc, "reportFocus" | "invokeReservedChord">;
29
+ readonly window: Window;
30
+ }): () => void;