@maiaos/client-core 0.0.0-stage → 0.1.0-nightly.20261024.5

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 (116) hide show
  1. package/README.md +124 -2
  2. package/dist/boot/boot.d.ts +63 -0
  3. package/dist/boot/boot.js +86 -0
  4. package/dist/boot/index.d.ts +3 -0
  5. package/dist/boot/index.js +2 -0
  6. package/dist/cockpit/attention.d.ts +23 -0
  7. package/dist/cockpit/attention.js +52 -0
  8. package/dist/cockpit/client.d.ts +88 -0
  9. package/dist/cockpit/client.js +190 -0
  10. package/dist/cockpit/cockpit.d.ts +75 -0
  11. package/dist/cockpit/cockpit.js +241 -0
  12. package/dist/cockpit/combine.d.ts +29 -0
  13. package/dist/cockpit/combine.js +89 -0
  14. package/dist/cockpit/creation.d.ts +128 -0
  15. package/dist/cockpit/creation.js +277 -0
  16. package/dist/cockpit/index.d.ts +26 -0
  17. package/dist/cockpit/index.js +13 -0
  18. package/dist/cockpit/launcher.d.ts +49 -0
  19. package/dist/cockpit/launcher.js +131 -0
  20. package/dist/cockpit/onboarding.d.ts +179 -0
  21. package/dist/cockpit/onboarding.js +371 -0
  22. package/dist/cockpit/overview.d.ts +63 -0
  23. package/dist/cockpit/overview.js +76 -0
  24. package/dist/cockpit/portfolio-index.d.ts +96 -0
  25. package/dist/cockpit/portfolio-index.js +236 -0
  26. package/dist/cockpit/portfolio-list.d.ts +76 -0
  27. package/dist/cockpit/portfolio-list.js +103 -0
  28. package/dist/cockpit/routing.d.ts +30 -0
  29. package/dist/cockpit/routing.js +38 -0
  30. package/dist/cockpit/triage.d.ts +24 -0
  31. package/dist/cockpit/triage.js +35 -0
  32. package/dist/commands/index.d.ts +3 -0
  33. package/dist/commands/index.js +2 -0
  34. package/dist/commands/registry.d.ts +80 -0
  35. package/dist/commands/registry.js +328 -0
  36. package/dist/dictation/dictation.d.ts +159 -0
  37. package/dist/dictation/dictation.js +223 -0
  38. package/dist/dictation/index.d.ts +3 -0
  39. package/dist/dictation/index.js +2 -0
  40. package/dist/id-generator/id-generator.d.ts +21 -0
  41. package/dist/id-generator/id-generator.js +33 -0
  42. package/dist/id-generator/index.d.ts +3 -0
  43. package/dist/id-generator/index.js +2 -0
  44. package/dist/index.d.ts +9 -0
  45. package/dist/index.js +5 -0
  46. package/dist/interaction/index.d.ts +5 -0
  47. package/dist/interaction/index.js +3 -0
  48. package/dist/interaction/interaction.d.ts +35 -0
  49. package/dist/interaction/interaction.js +103 -0
  50. package/dist/interaction/recovery.d.ts +190 -0
  51. package/dist/interaction/recovery.js +77 -0
  52. package/dist/interaction/view.d.ts +36 -0
  53. package/dist/interaction/view.js +102 -0
  54. package/dist/navigation/index.d.ts +5 -0
  55. package/dist/navigation/index.js +3 -0
  56. package/dist/navigation/pagination.d.ts +41 -0
  57. package/dist/navigation/pagination.js +43 -0
  58. package/dist/navigation/routes.d.ts +82 -0
  59. package/dist/navigation/routes.js +98 -0
  60. package/dist/node-registry/index.d.ts +3 -0
  61. package/dist/node-registry/index.js +2 -0
  62. package/dist/node-registry/node-registry.d.ts +81 -0
  63. package/dist/node-registry/node-registry.js +94 -0
  64. package/dist/node-status/index.d.ts +3 -0
  65. package/dist/node-status/index.js +2 -0
  66. package/dist/node-status/node-status.d.ts +38 -0
  67. package/dist/node-status/node-status.js +76 -0
  68. package/dist/portfolio/index.d.ts +3 -0
  69. package/dist/portfolio/index.js +2 -0
  70. package/dist/portfolio/portfolio.d.ts +143 -0
  71. package/dist/portfolio/portfolio.js +91 -0
  72. package/dist/presentation/collection-view.d.ts +29 -0
  73. package/dist/presentation/collection-view.js +134 -0
  74. package/dist/presentation/index.d.ts +14 -0
  75. package/dist/presentation/index.js +7 -0
  76. package/dist/presentation/live.d.ts +150 -0
  77. package/dist/presentation/live.js +53 -0
  78. package/dist/presentation/project-view.d.ts +59 -0
  79. package/dist/presentation/project-view.js +48 -0
  80. package/dist/presentation/service.d.ts +40 -0
  81. package/dist/presentation/service.js +151 -0
  82. package/dist/presentation/session-view.d.ts +134 -0
  83. package/dist/presentation/session-view.js +47 -0
  84. package/dist/presentation/view.d.ts +53 -0
  85. package/dist/presentation/view.js +29 -0
  86. package/dist/session-attach/index.d.ts +2 -0
  87. package/dist/session-attach/index.js +2 -0
  88. package/dist/session-attach/reducer.d.ts +6 -0
  89. package/dist/session-attach/reducer.js +6 -0
  90. package/dist/session-view/actions.d.ts +13 -0
  91. package/dist/session-view/actions.js +15 -0
  92. package/dist/session-view/conversation.d.ts +52 -0
  93. package/dist/session-view/conversation.js +119 -0
  94. package/dist/session-view/index.d.ts +11 -0
  95. package/dist/session-view/index.js +6 -0
  96. package/dist/session-view/live.d.ts +56 -0
  97. package/dist/session-view/live.js +112 -0
  98. package/dist/session-view/session-view.d.ts +92 -0
  99. package/dist/session-view/session-view.js +363 -0
  100. package/dist/session-view/timeline.d.ts +29 -0
  101. package/dist/session-view/timeline.js +43 -0
  102. package/dist/single-session/index.d.ts +3 -0
  103. package/dist/single-session/index.js +2 -0
  104. package/dist/single-session/single-session.d.ts +96 -0
  105. package/dist/single-session/single-session.js +228 -0
  106. package/dist/terminal/index.d.ts +4 -0
  107. package/dist/terminal/index.js +3 -0
  108. package/dist/terminal/terminal.d.ts +37 -0
  109. package/dist/terminal/terminal.js +131 -0
  110. package/dist/terminal/unavailable.d.ts +3 -0
  111. package/dist/terminal/unavailable.js +23 -0
  112. package/dist/workspace-scope/index.d.ts +3 -0
  113. package/dist/workspace-scope/index.js +2 -0
  114. package/dist/workspace-scope/workspace-scope.d.ts +45 -0
  115. package/dist/workspace-scope/workspace-scope.js +230 -0
  116. package/package.json +110 -3
package/README.md CHANGED
@@ -1,3 +1,125 @@
1
- # Temporary Holding Version
1
+ # @maiaos/client-core
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Platform-neutral client use cases for Maia OS: Effect services that sit between
4
+ a client's renderer and
5
+ [`@maiaos/sdk`](https://github.com/vllnt/maia/blob/main/packages/sdk/README.md),
6
+ so every client reads Maia Node state and sends requests the same way.
7
+
8
+ > **Pre-release.** npm carries only `nightly` and `canary` builds, which are
9
+ > not release-qualified. No stable release exists
10
+ > ([D-132](https://github.com/vllnt/maia/blob/main/docs/decisions.md#d-132)).
11
+ > Nothing in this package is a stability promise: entry points, names and
12
+ > shapes can change in any build.
13
+
14
+ Two Maia clients share it. The Expo client,
15
+ [`@maiaos/client`](https://github.com/vllnt/maia/blob/main/apps/client/README.md)
16
+ (Desktop and mobile), uses it today. The `maia` CLI will use it for its
17
+ terminal dashboard
18
+ ([D-161](https://github.com/vllnt/maia/blob/main/docs/decisions.md#d-161),
19
+ planned; the CLI does not use it yet).
20
+
21
+ ## Requirements
22
+
23
+ Node.js 24.22.1, and a Maia Node to talk to through `@maiaos/sdk`, which
24
+ installs with this package.
25
+
26
+ ## Install
27
+
28
+ ```sh
29
+ pnpm add @maiaos/client-core@nightly # or @canary, or an exact version
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ```ts
35
+ import { makeNodeStatusClientService } from "@maiaos/client-core/node-status";
36
+ import { validateVoiceProposal } from "@maiaos/client-core/single-session";
37
+ import { makeTerminalClientService } from "@maiaos/client-core/terminal";
38
+ import { makeWorkspaceScopeService } from "@maiaos/client-core/workspace-scope";
39
+ ```
40
+
41
+ Each factory returns an Effect service that the renderer composes with a
42
+ platform transport; React components only render its state.
43
+
44
+ The package owns client-side validation, deadlines, cancellation, recovery of
45
+ uncertain requests and projections of Node state. It owns no transport,
46
+ credentials, native API, Node lifecycle or durable state: the SDK is the
47
+ protocol client and Maia Node is the authority.
48
+
49
+ | Entry point | Responsibility |
50
+ | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
51
+ | `boot` | The Desktop boot sequence: lifecycle status, one start, then status reads until the host reports a ready Node |
52
+ | `navigation` | Route names, the `?q=&state=&page=` list query, parent routes and the one pagination model |
53
+ | `node-status` | One truthful, point-in-time Node status projection |
54
+ | `workspace-scope` | The one active Workspace scope, loaded from and replaced only by Node |
55
+ | `single-session` | Voice setup, Node-provided orchestrator context and local proposals |
56
+ | `terminal`, `terminal-unavailable` | Live terminal client and its explicit unavailable state |
57
+ | `interaction`, `interaction-view` | Record-only questions and answers, with recovery of unknown outcomes |
58
+ | `presentation` | Bounded Workspace, Project and Session list reads |
59
+ | `cockpit` | Work cockpit per Node and a combined view: one bounded index, one change feed, attention, lists, launcher search, single or bulk Session creation over the SDK batch and first-run onboarding |
60
+ | `session-view` | Focused Session detail: live items over `session.attach`, recent turns, summary, diff and a send, interrupt and wait composer |
61
+ | `commands` | The keyboard command registry: stable IDs, rebindable keymap, conflicts |
62
+ | `node-registry` | The local and registered remote Nodes a client addresses: connecting, ready, stale or unreachable (with ssh's reason, or `auth` when the Node rejects the credential) |
63
+ | `session-attach` | The SDK's Session attach reducer, re-exported unchanged |
64
+ | `dictation` | Composer dictation state: listening, partial text at the caret, cancel, language |
65
+
66
+ - **Deadlines and uncertainty.** Every request gets a unique message ID from
67
+ the package `IdGenerator` (Effect `Random` by default) and a 5.5-second
68
+ client deadline. A timed-out query is retryable; a timed-out
69
+ mutation is `outcome_unknown` and is reconciled with its original command
70
+ ID, never retried as a new one. Each request carries `deadlineAt`, that
71
+ deadline as an instant on `Clock.monotonicTimeNanos` (never wall-clock time),
72
+ for a requester that retries to stop before it. A client that must bound its
73
+ reads in flight provides `ReadAdmission`; a read waits for its permit before
74
+ its deadline starts, and a mutation never waits.
75
+ - **Freshness.** Each Node's cockpit keeps the time of its last successful
76
+ read and one whole-Node load in flight. A failed load retries, after 2 s and
77
+ doubling to 30 s, until the Node answers; a reload a person asks for runs
78
+ once; the change feed resubscribes on the same backoff. A copy is `stale`
79
+ when its latest whole-Node read failed and `partial` until the Node first
80
+ answers or while it caps a read. The combined view stays ready once any Node
81
+ answers.
82
+ - **Routing.** The combined view labels each Project and Session with its
83
+ Node, and `nodeIdOf` is the one lookup an action asks. It returns that Node,
84
+ or a `NodeRouteError` when the combined view holds no such target
85
+ (`unknown`) or two Nodes' indexes hold the same ID (`ambiguous`). A repeated
86
+ Workspace, Project or Session ID keeps the first Node's record and marks the
87
+ view truncated, and no action targets a repeated Project or Session. One
88
+ Node's own view routes every target to that Node, since its bounded index
89
+ can lack a Session the Node holds.
90
+ - **Fail closed.** Token, Workspace, orchestrator and relay services are
91
+ unavailable by default. A proposal or preview is never evidence of delivery;
92
+ only an exact Node preview enables explicit confirmation.
93
+ - **No hidden authority.** Browsing owns no execution focus or mutation
94
+ authority, and errors carry only bounded reason codes, never credentials or
95
+ request envelopes.
96
+
97
+ ## Status
98
+
99
+ **Source candidate.** Its terminal, voice, interaction and cockpit services are
100
+ not release-qualified. The cockpit follows today's `observe.events` feed; the
101
+ planned R1 aggregate subscription, view store and reconnect policy will replace
102
+ its index load here; see the
103
+ [client architecture](https://github.com/vllnt/maia/blob/main/docs/clients/architecture.md).
104
+
105
+ - **Bulk creation.** A preview fixes every target, task and command ID and
106
+ carries a digest; one confirmation runs it once, within its expiry. Each
107
+ item reports its receipts or the Node's refusal (for example `saturated`
108
+ when an account has no free slot). A retry replays the same commands and
109
+ request bodies, so the Node answers the original results. The Run ledger
110
+ (S19) joins as a second `CreationDispatch`.
111
+ - **Onboarding.** `makeOnboarding` reads vendor accounts from Connection
112
+ records, scans native sessions as metadata (titles only when a person turns
113
+ them on), registers repositories, and runs adoption as a preview, then one
114
+ confirmation request per item, each opening the Desktop native sheet. A declined or uncertain confirmation stops the batch;
115
+ confirming again resends the same preview IDs. One-click use of signed-in
116
+ CLIs waits for `connection.detect` (`CliDetection` placeholder).
117
+ - **Timeline seam.** `session-view` reads final turn text through a
118
+ `TimelineSource`; the S12b `reduceSessionAttach` source plugs in there.
119
+
120
+ ## Links
121
+
122
+ - [Desktop and mobile client architecture](https://github.com/vllnt/maia/blob/main/docs/clients/architecture.md)
123
+ - [`@maiaos/sdk`](https://github.com/vllnt/maia/blob/main/packages/sdk/README.md)
124
+ - [Security policy](https://github.com/vllnt/maia/blob/main/SECURITY.md)
125
+ - [Repository](https://github.com/vllnt/maia)
@@ -0,0 +1,63 @@
1
+ import { Duration, Effect, type Scope, type Stream } from "effect";
2
+ /**
3
+ * What the Desktop host reports about its local Node (`node_lifecycle_*`).
4
+ * The renderer never infers it: `ready` comes from the host alone, which
5
+ * checked the Node's descriptor and an authenticated `node.status`.
6
+ */
7
+ export type NodeLifecycleState = {
8
+ readonly instanceId: string;
9
+ readonly state: "ready";
10
+ } | {
11
+ readonly reason: string;
12
+ readonly state: "failed" | "unavailable";
13
+ } | {
14
+ readonly state: "detecting" | "starting";
15
+ };
16
+ declare const NodeLifecycleError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
17
+ readonly _tag: "NodeLifecycleError";
18
+ } & Readonly<A>;
19
+ export declare class NodeLifecycleError extends NodeLifecycleError_base<{
20
+ readonly message: string;
21
+ }> {
22
+ }
23
+ /** The host's two lifecycle commands, lifted into Effect at the platform edge. */
24
+ export type NodeLifecycle = {
25
+ readonly start: Effect.Effect<NodeLifecycleState, NodeLifecycleError>;
26
+ readonly status: Effect.Effect<NodeLifecycleState, NodeLifecycleError>;
27
+ };
28
+ /**
29
+ * Where the boot sequence is. `waiting` while the host starts or detects a
30
+ * Node; `stopped` when it gave up and a person may start it again; `ready`
31
+ * once the host reports a ready Node; routes read it from then on.
32
+ */
33
+ export type BootState = {
34
+ readonly instanceId: string;
35
+ readonly phase: "ready";
36
+ } | {
37
+ readonly phase: "checking";
38
+ } | {
39
+ readonly phase: "stopped";
40
+ readonly reason: string;
41
+ } | {
42
+ readonly phase: "waiting";
43
+ };
44
+ export type BootOptions = {
45
+ /** First pause between status reads; doubles up to `maxDelay`. */
46
+ readonly initialDelay?: Duration.Input;
47
+ readonly maxDelay?: Duration.Input;
48
+ /** How long to wait for a starting Node before stopping. */
49
+ readonly timeout?: Duration.Input;
50
+ };
51
+ export type Boot = {
52
+ readonly changes: Stream.Stream<BootState>;
53
+ readonly current: Effect.Effect<BootState>;
54
+ /** Runs the sequence again (the "Start Maia" action); ignored while one runs. */
55
+ readonly restart: Effect.Effect<void>;
56
+ };
57
+ /**
58
+ * Starts the boot sequence in the caller's scope. Routes mount once it
59
+ * publishes `ready`; closing the scope interrupts a sequence in flight.
60
+ */
61
+ export declare const makeBoot: (lifecycle: NodeLifecycle, options?: BootOptions) => Effect.Effect<Boot, never, Scope.Scope>;
62
+ export {};
63
+ //# sourceMappingURL=boot.d.ts.map
@@ -0,0 +1,86 @@
1
+ import { Data, Duration, Effect, Semaphore, SubscriptionRef, } from "effect";
2
+ export class NodeLifecycleError extends Data.TaggedError("NodeLifecycleError") {
3
+ }
4
+ /** The host starts its Node in up to 60 seconds; leave headroom. */
5
+ const DEFAULTS = {
6
+ initialDelay: Duration.millis(250),
7
+ maxDelay: Duration.seconds(2),
8
+ timeout: Duration.seconds(75),
9
+ };
10
+ const settled = (lifecycle) => {
11
+ if (lifecycle.state === "ready")
12
+ return { instanceId: lifecycle.instanceId, phase: "ready" };
13
+ if (lifecycle.state === "failed" || lifecycle.state === "unavailable")
14
+ return { phase: "stopped", reason: lifecycle.reason };
15
+ return undefined;
16
+ };
17
+ /** A bridge call that fails reads as an unreachable host. */
18
+ const unreachable = {
19
+ reason: "host_unreachable",
20
+ state: "unavailable",
21
+ };
22
+ const read = (effect) => effect.pipe(Effect.catch(() => Effect.succeed(unreachable)));
23
+ /**
24
+ * A status read while the Node starts: a failed call is one missed answer,
25
+ * not a verdict, so the sequence keeps waiting until its deadline.
26
+ */
27
+ const poll = (effect) => effect.pipe(Effect.catch(() => Effect.succeed({ state: "starting" })));
28
+ /**
29
+ * Status reads with a growing pause until the host reports the Node ready
30
+ * or stopped. The caller bounds the whole wait.
31
+ */
32
+ const waitForReady = (run, current, delay) => Effect.gen(function* () {
33
+ const outcome = settled(current);
34
+ if (outcome !== undefined)
35
+ return outcome;
36
+ yield* Effect.sleep(delay);
37
+ return yield* waitForReady(run, yield* poll(run.lifecycle.status), Duration.min(Duration.times(delay, 2), run.options.maxDelay));
38
+ });
39
+ /**
40
+ * Status; unless the Node is ready, one start (the host's start is
41
+ * idempotent: it adopts a running Node, keeps a start in flight, or spawns
42
+ * one); then status reads until the host reports ready or stopped. The
43
+ * whole sequence, every host call included, ends at the timeout. The state
44
+ * follows every step, so the screen tells the truth.
45
+ */
46
+ const sequence = (run) => Effect.gen(function* () {
47
+ yield* SubscriptionRef.set(run.ref, { phase: "checking" });
48
+ const first = yield* read(run.lifecycle.status);
49
+ if (first.state === "ready")
50
+ return yield* SubscriptionRef.set(run.ref, {
51
+ instanceId: first.instanceId,
52
+ phase: "ready",
53
+ });
54
+ yield* SubscriptionRef.set(run.ref, { phase: "waiting" });
55
+ const outcome = yield* waitForReady(run, yield* read(run.lifecycle.start), run.options.initialDelay);
56
+ yield* SubscriptionRef.set(run.ref, outcome);
57
+ }).pipe(Effect.timeoutOrElse({
58
+ duration: run.options.timeout,
59
+ orElse: () => SubscriptionRef.set(run.ref, {
60
+ phase: "stopped",
61
+ reason: "readiness_timeout",
62
+ }),
63
+ }));
64
+ const durations = (options) => ({
65
+ initialDelay: Duration.fromInputUnsafe(options.initialDelay ?? DEFAULTS.initialDelay),
66
+ maxDelay: Duration.fromInputUnsafe(options.maxDelay ?? DEFAULTS.maxDelay),
67
+ timeout: Duration.fromInputUnsafe(options.timeout ?? DEFAULTS.timeout),
68
+ });
69
+ /**
70
+ * Starts the boot sequence in the caller's scope. Routes mount once it
71
+ * publishes `ready`; closing the scope interrupts a sequence in flight.
72
+ */
73
+ export const makeBoot = (lifecycle, options = {}) => Effect.gen(function* () {
74
+ const ref = yield* SubscriptionRef.make({ phase: "checking" });
75
+ const lock = yield* Semaphore.make(1);
76
+ const scope = yield* Effect.scope;
77
+ const run = { lifecycle, options: durations(options), ref };
78
+ const once = Effect.forkIn(lock.withPermitsIfAvailable(1)(sequence(run)), scope).pipe(Effect.asVoid);
79
+ yield* once;
80
+ return {
81
+ changes: SubscriptionRef.changes(ref),
82
+ current: SubscriptionRef.get(ref),
83
+ restart: once,
84
+ };
85
+ });
86
+ //# sourceMappingURL=boot.js.map
@@ -0,0 +1,3 @@
1
+ export type { Boot, BootOptions, BootState, NodeLifecycle, NodeLifecycleState, } from "./boot.js";
2
+ export { makeBoot, NodeLifecycleError } from "./boot.js";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export { makeBoot, NodeLifecycleError } from "./boot.js";
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,23 @@
1
+ import type { Session, SessionObservation } from "@maiaos/sdk";
2
+ export type SessionRuntime = NonNullable<SessionObservation["runtime"]>;
3
+ /**
4
+ * Why a Session needs a person, as the Node reports it (`session.list` and
5
+ * `session.observe` carry it). The client derives none of these values.
6
+ */
7
+ export type AttentionReason = NonNullable<SessionObservation["attention"]>;
8
+ export type AttentionItem = {
9
+ readonly projectId: string;
10
+ readonly reason: AttentionReason;
11
+ readonly sessionId: string;
12
+ readonly updatedAt: string;
13
+ };
14
+ /** Bounds the list a person triages; the portfolio list shows the rest. */
15
+ export declare const MAX_ATTENTION_ITEMS = 500;
16
+ /**
17
+ * The ordered, bounded list of Sessions the Node says need a person now.
18
+ * A Session the Node reports nothing for is absent.
19
+ */
20
+ export declare const deriveAttention: (sessions: Iterable<Session>, reasons: ReadonlyMap<string, AttentionReason>) => readonly AttentionItem[];
21
+ /** Short labels for people; the Node's value stays the source. */
22
+ export declare const attentionLabel: (reason: AttentionReason) => string;
23
+ //# sourceMappingURL=attention.d.ts.map
@@ -0,0 +1,52 @@
1
+ /** Most urgent first: a person blocks the work, then failures, then review. */
2
+ const SEVERITY = [
3
+ "approval_pending",
4
+ "question",
5
+ "failed",
6
+ "turn_failed",
7
+ "stalled",
8
+ "settled_unreviewed",
9
+ ];
10
+ /** Bounds the list a person triages; the portfolio list shows the rest. */
11
+ export const MAX_ATTENTION_ITEMS = 500;
12
+ const byUrgency = (left, right) => SEVERITY.indexOf(left.reason) - SEVERITY.indexOf(right.reason) ||
13
+ right.updatedAt.localeCompare(left.updatedAt) ||
14
+ left.sessionId.localeCompare(right.sessionId);
15
+ /**
16
+ * The ordered, bounded list of Sessions the Node says need a person now.
17
+ * A Session the Node reports nothing for is absent.
18
+ */
19
+ export const deriveAttention = (sessions, reasons) => [...sessions]
20
+ .flatMap((session) => {
21
+ const reason = reasons.get(session.id);
22
+ return reason === undefined
23
+ ? []
24
+ : [
25
+ {
26
+ projectId: session.projectId,
27
+ reason,
28
+ sessionId: session.id,
29
+ updatedAt: session.updatedAt,
30
+ },
31
+ ];
32
+ })
33
+ .toSorted(byUrgency)
34
+ .slice(0, MAX_ATTENTION_ITEMS);
35
+ /** Short labels for people; the Node's value stays the source. */
36
+ export const attentionLabel = (reason) => {
37
+ switch (reason) {
38
+ case "approval_pending":
39
+ return "Needs approval";
40
+ case "failed":
41
+ return "Failed";
42
+ case "question":
43
+ return "Question";
44
+ case "settled_unreviewed":
45
+ return "Review";
46
+ case "stalled":
47
+ return "Stalled";
48
+ case "turn_failed":
49
+ return "Turn failed";
50
+ }
51
+ };
52
+ //# sourceMappingURL=attention.js.map
@@ -0,0 +1,88 @@
1
+ import { type LocalNodeClient } from "@maiaos/sdk";
2
+ import { Context, Effect } from "effect";
3
+ /** The SDK operations the cockpit, creation, Session detail and onboarding use. */
4
+ export type CockpitClient = Pick<LocalNodeClient, "archiveSession" | "confirmSessionAdopt" | "createSession" | "detectConnections" | "diffSession" | "getReceipt" | "interruptTurn" | "listConnections" | "listContainerIndex" | "listDiscoveredSessions" | "listRemoteNodes" | "listSessionEvents" | "listSessions" | "listWorkspaces" | "observeSessions" | "previewSessionAdopt" | "registerProject" | "respondToApproval" | "resumeSession" | "sendTurn" | "showApproval" | "showSession" | "showTurn" | "startSession" | "stopSession" | "waitTurns">;
5
+ /**
6
+ * Every Node operation the cockpit, creation, Session detail and onboarding
7
+ * send. Each request names one of them, so a test can hold the Desktop
8
+ * allowlist against this list.
9
+ */
10
+ export declare const COCKPIT_OPERATIONS: readonly ["approval.respond", "approval.show", "connection.detect", "connection.list", "container.index.list", "discovery.sessions.list", "node.remote.list", "project.register", "session.adopt.confirm", "session.adopt.preview", "session.archive", "session.create", "session.diff", "session.events.list", "session.list", "session.observe", "session.resume", "session.show", "session.start", "session.stop", "turn.interrupt", "turn.send", "turn.show", "turn.wait", "workspace.list"];
11
+ export type CockpitOperation = (typeof COCKPIT_OPERATIONS)[number];
12
+ /**
13
+ * Why a request did not succeed. `refused` carries the Node's protocol code
14
+ * (for example `saturated` when an account has no free Session slot);
15
+ * `declined` means a person declined the native confirmation and nothing was
16
+ * sent; `sheet_busy` means another native confirmation was open or none was
17
+ * available, so the Node got nothing and the same request may run again;
18
+ * `outcome_unknown` means a mutation may have committed and must be
19
+ * reconciled with its original command ID.
20
+ */
21
+ export type CockpitErrorReason = "declined" | "denied" | "invalid" | "offline" | "outcome_unknown" | "refused" | "sheet_busy" | "unavailable";
22
+ declare const CockpitError_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
23
+ readonly _tag: "CockpitError";
24
+ } & Readonly<A>;
25
+ export declare class CockpitError extends CockpitError_base<{
26
+ /** The Node's protocol error code when it refused the request. */
27
+ readonly code?: string;
28
+ /**
29
+ * The Node's own explanation of a refusal, which often names the fix, or
30
+ * why a remote Node is unreachable, such as the last line ssh printed.
31
+ */
32
+ readonly detail?: string;
33
+ readonly message: string;
34
+ readonly operation: string;
35
+ readonly reason: CockpitErrorReason;
36
+ }> {
37
+ }
38
+ /** How long the client waits for an ordinary request. */
39
+ export declare const REQUEST_DEADLINE_MILLISECONDS = 5500;
40
+ /**
41
+ * How long the client waits for a request behind the native confirmation:
42
+ * the sheet declines itself after two minutes (the Desktop's
43
+ * `CONFIRMATION_WINDOW_SECONDS`), then the Node deadline starts. A test
44
+ * holds this above the two together.
45
+ */
46
+ export declare const CONFIRMATION_DEADLINE_MILLISECONDS = 150000;
47
+ /**
48
+ * The Desktop bridge's code for a request it refused to send, such as an
49
+ * approval answer whose request it no longer remembers, or one to a remote
50
+ * Node whose registration changed.
51
+ */
52
+ export declare const PLATFORM_DENIED = "request_denied";
53
+ /**
54
+ * Classifies a failure with the SDK. A mutation the Node never answered is
55
+ * `outcome_unknown`, because it may have committed.
56
+ */
57
+ export declare const toCockpitError: (operation: string, error: unknown, mutation?: boolean) => CockpitError;
58
+ type RequestOptions = {
59
+ /** When the client stops waiting, on `Clock.monotonicTimeNanos`. */
60
+ readonly deadlineAt: bigint;
61
+ readonly messageId: string;
62
+ readonly signal: AbortSignal;
63
+ };
64
+ /**
65
+ * Admits a read before its deadline starts, so a wait for a permit never
66
+ * counts against the deadline. It never admits a mutation. The default admits
67
+ * every read at once, as the Desktop does; a client that must bound reads in
68
+ * flight provides one, such as a `Semaphore`'s `withPermits(1)`.
69
+ */
70
+ export type ReadAdmissionService = <TValue, TError>(effect: Effect.Effect<TValue, TError>) => Effect.Effect<TValue, TError>;
71
+ declare const ReadAdmission_base: Context.Reference<ReadAdmissionService>;
72
+ export declare class ReadAdmission extends ReadAdmission_base {
73
+ }
74
+ /** A read: a fresh message ID, the client deadline and bounded errors. */
75
+ export declare const read: <T>(operation: CockpitOperation, request: (options: RequestOptions) => Promise<T>) => Effect.Effect<T, CockpitError>;
76
+ /**
77
+ * A mutation: a timeout is `outcome_unknown`, never a failure, because the
78
+ * Node may have committed it; callers reconcile with the same command ID.
79
+ */
80
+ export declare const mutate: <T>(operation: CockpitOperation, request: (options: RequestOptions) => Promise<T>) => Effect.Effect<T, CockpitError>;
81
+ /**
82
+ * A mutation behind the native confirmation sheet: the client waits as long
83
+ * as the sheet may stay open, and a timeout is `outcome_unknown` as above.
84
+ */
85
+ export declare const confirmMutation: <T>(operation: CockpitOperation, request: (options: RequestOptions) => Promise<T>) => Effect.Effect<T, CockpitError>;
86
+ export declare const unavailable: (operation: string) => CockpitError;
87
+ export {};
88
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1,190 @@
1
+ import { classifyClientError, MaiaClientError, } from "@maiaos/sdk";
2
+ import { Clock, Context, Data, Duration, Effect } from "effect";
3
+ import { nextCompactId } from "../id-generator/index.js";
4
+ /**
5
+ * Every Node operation the cockpit, creation, Session detail and onboarding
6
+ * send. Each request names one of them, so a test can hold the Desktop
7
+ * allowlist against this list.
8
+ */
9
+ export const COCKPIT_OPERATIONS = [
10
+ "approval.respond",
11
+ "approval.show",
12
+ "connection.detect",
13
+ "connection.list",
14
+ "container.index.list",
15
+ "discovery.sessions.list",
16
+ "node.remote.list",
17
+ "project.register",
18
+ "session.adopt.confirm",
19
+ "session.adopt.preview",
20
+ "session.archive",
21
+ "session.create",
22
+ "session.diff",
23
+ "session.events.list",
24
+ "session.list",
25
+ "session.observe",
26
+ "session.resume",
27
+ "session.show",
28
+ "session.start",
29
+ "session.stop",
30
+ "turn.interrupt",
31
+ "turn.send",
32
+ "turn.show",
33
+ "turn.wait",
34
+ "workspace.list",
35
+ ];
36
+ export class CockpitError extends Data.TaggedError("CockpitError") {
37
+ }
38
+ /** How long the client waits for an ordinary request. */
39
+ export const REQUEST_DEADLINE_MILLISECONDS = 5500;
40
+ /**
41
+ * How long the client waits for a request behind the native confirmation:
42
+ * the sheet declines itself after two minutes (the Desktop's
43
+ * `CONFIRMATION_WINDOW_SECONDS`), then the Node deadline starts. A test
44
+ * holds this above the two together.
45
+ */
46
+ export const CONFIRMATION_DEADLINE_MILLISECONDS = 150_000;
47
+ /**
48
+ * The Desktop bridge's code for a request it refused to send, such as an
49
+ * approval answer whose request it no longer remembers, or one to a remote
50
+ * Node whose registration changed.
51
+ */
52
+ export const PLATFORM_DENIED = "request_denied";
53
+ const nativeField = (error, field) => {
54
+ const cause = error.cause;
55
+ return cause !== null && typeof cause === "object"
56
+ ? Reflect.get(cause, field)
57
+ : undefined;
58
+ };
59
+ const nativeReason = (error) => nativeField(error, "reason");
60
+ const MAX_DETAIL_LENGTH = 240;
61
+ /** The native bridge's bounded reason text, never an envelope or credential. */
62
+ const nativeDetail = (error) => {
63
+ const detail = nativeField(error, "detail");
64
+ return typeof detail === "string" && detail !== ""
65
+ ? detail.slice(0, MAX_DETAIL_LENGTH)
66
+ : undefined;
67
+ };
68
+ const message = (reason) => {
69
+ switch (reason) {
70
+ case "declined":
71
+ return "Declined in the confirmation dialog; nothing was sent.";
72
+ case "denied":
73
+ return "The Node denied this request.";
74
+ case "invalid":
75
+ return "The Node answer did not match the protocol.";
76
+ case "offline":
77
+ return "The Node did not answer.";
78
+ case "outcome_unknown":
79
+ return "The Node may have applied this change; reconcile it with the same command.";
80
+ case "refused":
81
+ return "The Node refused this request.";
82
+ case "sheet_busy":
83
+ return "Another confirmation is open or none is available; nothing was sent. Try again.";
84
+ case "unavailable":
85
+ return "This operation is not available on this client or Node.";
86
+ }
87
+ };
88
+ const failure = (operation, reason, code) => new CockpitError({
89
+ ...(code === undefined ? {} : { code }),
90
+ message: code === PLATFORM_DENIED
91
+ ? "The Desktop bridge refused this request; nothing was applied."
92
+ : message(reason),
93
+ operation,
94
+ reason,
95
+ });
96
+ /** A Node refusal keeps the Node's own explanation. */
97
+ const refusedBy = (operation, error) => new CockpitError({
98
+ ...(error.remoteError === undefined
99
+ ? {}
100
+ : { code: error.remoteError.code, detail: error.remoteError.message }),
101
+ message: message("refused"),
102
+ operation,
103
+ reason: "refused",
104
+ });
105
+ /** A remote Node whose bridge is opening or failed, with ssh's reason. */
106
+ const remoteFailure = (operation, code, error) => {
107
+ const detail = nativeDetail(error);
108
+ return new CockpitError({
109
+ code,
110
+ ...(detail === undefined ? {} : { detail }),
111
+ message: message("offline"),
112
+ operation,
113
+ reason: "offline",
114
+ });
115
+ };
116
+ /** The SDK's classification in the cockpit's words. */
117
+ const reasonFor = (kind) => {
118
+ if (kind === "cancelled")
119
+ return "offline";
120
+ return kind === "conflict" || kind === "not_found" ? "refused" : kind;
121
+ };
122
+ /**
123
+ * Classifies a failure with the SDK. A mutation the Node never answered is
124
+ * `outcome_unknown`, because it may have committed.
125
+ */
126
+ export const toCockpitError = (operation, error, mutation = false) => {
127
+ if (error instanceof CockpitError)
128
+ return error;
129
+ // A remote Node's bridge reports an opening or failed bridge before the
130
+ // request left, with ssh's reason; the SDK has no word for that.
131
+ if (error instanceof MaiaClientError) {
132
+ const native = nativeReason(error);
133
+ if (native === "node_connecting" || native === "node_unreachable")
134
+ return remoteFailure(operation, native, error);
135
+ }
136
+ const { code, kind } = classifyClientError(error, { mutation });
137
+ if (code === "confirmation_unavailable")
138
+ return failure(operation, "sheet_busy", code);
139
+ const reason = reasonFor(kind);
140
+ return reason === "refused" &&
141
+ error instanceof MaiaClientError &&
142
+ error.remoteError !== undefined
143
+ ? refusedBy(operation, error)
144
+ : failure(operation, reason, code);
145
+ };
146
+ const admitAtOnce = (effect) => effect;
147
+ export class ReadAdmission extends Context.Reference("@maiaos/client-core/ReadAdmission", { defaultValue: () => admitAtOnce }) {
148
+ }
149
+ const send = (operation, request, { deadline, mutation }) => {
150
+ // One monotonic read sets the cutoff, then the timeout starts: the cutoff is
151
+ // never later than the timeout, and a wall clock that steps back moves neither.
152
+ const attempt = Effect.flatMap(Clock.monotonicTimeNanos, (now) => Effect.flatMap(nextCompactId("cockpit"), (messageId) => Effect.tryPromise({
153
+ catch: (error) => toCockpitError(operation, error, mutation),
154
+ try: (signal) => request({
155
+ deadlineAt: now + Duration.toNanosUnsafe(deadline),
156
+ messageId,
157
+ signal,
158
+ }),
159
+ })).pipe(Effect.timeoutOrElse({
160
+ duration: deadline,
161
+ orElse: () => Effect.fail(failure(operation, "offline", "timed_out")),
162
+ })));
163
+ return mutation
164
+ ? attempt
165
+ : Effect.flatMap(Effect.service(ReadAdmission), (admit) => admit(attempt));
166
+ };
167
+ /** A read: a fresh message ID, the client deadline and bounded errors. */
168
+ export const read = (operation, request) => send(operation, request, {
169
+ deadline: REQUEST_DEADLINE_MILLISECONDS,
170
+ mutation: false,
171
+ });
172
+ const unknownOnTimeout = (operation, effect) => effect.pipe(Effect.catchIf((error) => error.code === "timed_out" || error.code === "deadline_exceeded", () => Effect.fail(failure(operation, "outcome_unknown"))));
173
+ /**
174
+ * A mutation: a timeout is `outcome_unknown`, never a failure, because the
175
+ * Node may have committed it; callers reconcile with the same command ID.
176
+ */
177
+ export const mutate = (operation, request) => unknownOnTimeout(operation, send(operation, request, {
178
+ deadline: REQUEST_DEADLINE_MILLISECONDS,
179
+ mutation: true,
180
+ }));
181
+ /**
182
+ * A mutation behind the native confirmation sheet: the client waits as long
183
+ * as the sheet may stay open, and a timeout is `outcome_unknown` as above.
184
+ */
185
+ export const confirmMutation = (operation, request) => unknownOnTimeout(operation, send(operation, request, {
186
+ deadline: CONFIRMATION_DEADLINE_MILLISECONDS,
187
+ mutation: true,
188
+ }));
189
+ export const unavailable = (operation) => failure(operation, "unavailable");
190
+ //# sourceMappingURL=client.js.map