opencode-session-port 0.0.0-stage → 0.1.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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +84 -2
  3. package/package.json +56 -4
  4. package/src/core/contracts/crypto.ts +66 -0
  5. package/src/core/contracts/index.ts +13 -0
  6. package/src/core/contracts/json.ts +142 -0
  7. package/src/core/contracts/session.ts +12 -0
  8. package/src/core/crypto/container.ts +686 -0
  9. package/src/core/crypto/envelope.ts +232 -0
  10. package/src/core/crypto/errors.ts +52 -0
  11. package/src/core/crypto/gzip-stream.ts +388 -0
  12. package/src/core/crypto/index.ts +13 -0
  13. package/src/core/crypto/kdf.ts +101 -0
  14. package/src/core/crypto/policy.ts +202 -0
  15. package/src/core/crypto/zero.ts +33 -0
  16. package/src/core/restore/identity.ts +202 -0
  17. package/src/core/restore/index.ts +24 -0
  18. package/src/core/restore/planner.ts +1122 -0
  19. package/src/core/sessions/collector.ts +323 -0
  20. package/src/core/sessions/errors.ts +37 -0
  21. package/src/core/sessions/guards.ts +11 -0
  22. package/src/core/sessions/index.ts +29 -0
  23. package/src/core/sessions/policy.ts +80 -0
  24. package/src/core/sessions/port.ts +88 -0
  25. package/src/core/sessions/serialize.ts +15 -0
  26. package/src/core/sessions/unsettled.ts +35 -0
  27. package/src/integrations/omo/index.ts +28 -0
  28. package/src/tui/adapters/host-client.ts +254 -0
  29. package/src/tui/adapters/index.ts +72 -0
  30. package/src/tui/adapters/location-resolver.ts +281 -0
  31. package/src/tui/adapters/project-destinations.ts +271 -0
  32. package/src/tui/adapters/session-id-inventory.ts +378 -0
  33. package/src/tui/adapters/session-import.ts +189 -0
  34. package/src/tui/adapters/session-read.ts +115 -0
  35. package/src/tui/artifacts/files.ts +285 -0
  36. package/src/tui/orchestration/checkpoint.ts +160 -0
  37. package/src/tui/orchestration/index.ts +55 -0
  38. package/src/tui/orchestration/restore-auth.ts +94 -0
  39. package/src/tui/orchestration/restore-execution.ts +381 -0
  40. package/src/tui/orchestration/restore-preflight.ts +223 -0
  41. package/src/tui/orchestration/shared.ts +75 -0
  42. package/src/tui/ui/password-dialog.tsx +251 -0
  43. package/src/tui/ui/password-flow.ts +288 -0
  44. package/src/tui/ui/restore-project-selector.ts +62 -0
  45. package/src/tui/ui/restore-report-dialog.tsx +251 -0
  46. package/src/tui/ui/restore-report-flow.ts +94 -0
  47. package/src/tui/ui/sessionport-commands.tsx +65 -0
  48. package/src/tui/wiring/composition.ts +354 -0
  49. package/src/tui/wiring/experimental-profile.ts +86 -0
  50. package/src/tui/wiring/index.ts +51 -0
  51. package/src/tui/wiring/install.ts +108 -0
  52. package/src/tui/wiring/reports.ts +340 -0
  53. package/src/tui.ts +34 -0
  54. package/tsconfig.json +18 -0
  55. package/tui.ts +12 -0
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Narrow structural boundary over the public TUI `context.client` surface.
3
+ *
4
+ * These adapters never import the host runtime; they accept the host-provided
5
+ * client as an untrusted value, resolve the specific methods they use with
6
+ * runtime checks, and call only through public client methods. The narrow
7
+ * interfaces below document the exact surface the adapters call and are used
8
+ * for compile-time assertions against the installed OpenCode v2.0.22 TUI
9
+ * declaration (`Plugin.Context["client"]`, the generated `OpenCodeClient`).
10
+ *
11
+ * Declaration boundary (O-12/OQ-12): the installed plugin hook `SessionDomain`
12
+ * declaration omits `session.import` while the generated TUI client
13
+ * declaration includes it; the generated client is also what the TUI plugin
14
+ * context actually carries. This module therefore does not depend on any host
15
+ * type at runtime: capability is detected at runtime (see
16
+ * `probeSessionImportCapability`), and the compile-time assertions below only
17
+ * pin the installed declaration relation so `tsc` flags a drift. No blanket
18
+ * `any` is used anywhere; the single unknown-to-callable conversion point is
19
+ * `resolveUnaryMethod`, which narrows a value proven callable by `typeof` to a typed call signature
20
+ * and always invokes it with its owning object (`client[container]`) preserved
21
+ * as the receiver, so a `this`-dependent host method behaves exactly as a
22
+ * direct `client[container][method](input)` call would.
23
+ *
24
+ * Bounded evidence only: the v2.0.22 declaration/source research (E13/E14/E18)
25
+ * and the one-run actual-TUI probe (E33) are not a cross-version or support
26
+ * claim. Do not extend them.
27
+ */
28
+
29
+ import type { Plugin } from "@opencode/plugin/tui";
30
+ import type { JsonObject } from "../../core/contracts/index.ts";
31
+
32
+ /* ------------------------------------------------------------------------- */
33
+ /* Errors */
34
+ /* ------------------------------------------------------------------------- */
35
+
36
+ /** Stable machine-readable failure codes for host adapter setup/invocation. */
37
+ export type HostAdapterErrorCode =
38
+ /** The installed client does not expose the required callable method. */
39
+ | "capability-unavailable"
40
+ /** The adapter was called with a request that violates the core port contract. */
41
+ | "invalid-request"
42
+ /** The host call resolved with a value that violates the declared response contract. */
43
+ | "invalid-response";
44
+
45
+ /**
46
+ * Fail-closed adapter error. Never wraps or replaces host API errors: errors
47
+ * thrown by the public client itself propagate unchanged so restore/checkpoint
48
+ * reporting stays truthful.
49
+ */
50
+ export class HostAdapterError extends Error {
51
+ readonly code: HostAdapterErrorCode;
52
+
53
+ constructor(code: HostAdapterErrorCode, message: string, options?: ErrorOptions) {
54
+ super(message, options);
55
+ this.name = "HostAdapterError";
56
+ this.code = code;
57
+ }
58
+ }
59
+
60
+ /* ------------------------------------------------------------------------- */
61
+ /* Narrow client surface (documentation + compile-time assertions) */
62
+ /* ------------------------------------------------------------------------- */
63
+
64
+ /** One `session.list` request as mapped from `SessionListPageRequest`. */
65
+ export interface SessionListCallInput {
66
+ /** Explicit project scope; mapped to the list route's `project` filter. */
67
+ readonly project: string;
68
+ /** Explicit page size; the API default is never relied on. */
69
+ readonly limit: number;
70
+ /** Opaque `cursor.next`; absent on the first request. */
71
+ readonly cursor?: string;
72
+ }
73
+
74
+ /** One `message.list` request as mapped from `MessageListPageRequest`. */
75
+ export interface MessageListCallInput {
76
+ /** Route-scoped session ID (the ownership boundary). */
77
+ readonly sessionID: string;
78
+ /** Explicit page size; the API default is never relied on. */
79
+ readonly limit: number;
80
+ /** First page only: ascending order. */
81
+ readonly order?: "asc";
82
+ /** Subsequent pages only: opaque cursor; no `order`, no `type` filter. */
83
+ readonly cursor?: string;
84
+ }
85
+
86
+ /**
87
+ * Narrow structural view of the public read surface used for enumeration.
88
+ *
89
+ * All members are optional because capability absence is a real runtime state
90
+ * (E1/OQ-12): the adapters fail closed with a clear `HostAdapterError` when a
91
+ * method is missing or not callable. Method syntax is intentional: it keeps
92
+ * the installed generated client assignable (bivariant parameter check) while
93
+ * the runtime resolver below still validates callability before every use.
94
+ */
95
+ export interface SessionReadClientLike {
96
+ readonly session?: {
97
+ list?(input: SessionListCallInput): Promise<unknown>;
98
+ };
99
+ readonly message?: {
100
+ list?(input: MessageListCallInput): Promise<unknown>;
101
+ };
102
+ }
103
+
104
+ /**
105
+ * One `session.import` call payload: the final planned record (`info` and
106
+ * `messages` are passed through verbatim, never re-derived here) plus the
107
+ * explicit target location supplied by the caller.
108
+ *
109
+ * `location` is typed optional/nullable only so the installed v2.0.22
110
+ * generated declaration (`SessionImportInput`, whose `location` is optional
111
+ * and nullable) remains assignable to this narrow view; the import adapter
112
+ * always supplies it (O-16: V1 always supplies an explicit target).
113
+ */
114
+ export interface SessionImportCallInput {
115
+ readonly info: JsonObject;
116
+ readonly messages: readonly JsonObject[];
117
+ readonly location?: { readonly directory: string } | null;
118
+ }
119
+
120
+ /**
121
+ * Validated narrow view of one `session.import` response.
122
+ *
123
+ * The pinned v2.0.22 public TUI client (`context.client`, the generated promise
124
+ * client) unwraps the raw protocol `{ data: SessionInfo }` envelope and
125
+ * resolves the `SessionInfo` directly: its declaration is
126
+ * `import(input) => Promise<SessionInfo>`, and `SessionInfo` carries
127
+ * non-optional `id`, `projectID`, and `location.directory`. The generated
128
+ * client casts the JSON body to that declared type without runtime validation,
129
+ * so the import adapter re-proves these fields at this dynamic boundary before
130
+ * treating a response as confirmed; it returns the original response value
131
+ * unchanged after narrowing. Only the fields SessionPort relies on are
132
+ * declared here.
133
+ */
134
+ export interface SessionImportResult {
135
+ readonly id: string;
136
+ readonly projectID: string;
137
+ readonly location: { readonly directory: string };
138
+ }
139
+
140
+ /**
141
+ * Narrow structural view of the public import surface. `import` is optional
142
+ * because it may be absent or non-callable at runtime (O-12 policy: probe at
143
+ * restore start and stop with a clear message before reading or decrypting).
144
+ * The resolved value is intentionally `unknown` at this raw boundary: the
145
+ * generated client does not runtime-validate its JSON, so the import adapter
146
+ * proves the declared `SessionInfo` fields (`SessionImportResult`) before a
147
+ * response is treated as confirmed.
148
+ */
149
+ export interface SessionImportClientLike {
150
+ readonly session?: {
151
+ import?(input: SessionImportCallInput): Promise<unknown>;
152
+ };
153
+ }
154
+
155
+ /* ------------------------------------------------------------------------- */
156
+ /* Compile-time assertions against the installed TUI client declaration */
157
+ /* ------------------------------------------------------------------------- */
158
+
159
+ type HostTuiClient = Plugin.Context["client"];
160
+ type AssertTrue<T extends true> = T;
161
+
162
+ /**
163
+ * The installed v2.0.22 TUI client (`Plugin.Context["client"]`) must satisfy
164
+ * the narrow read surface; if a future pinned declaration drifts, `tsc` fails
165
+ * here instead of failing at runtime.
166
+ */
167
+ type _AssertHostReadClientCompatible = AssertTrue<HostTuiClient extends SessionReadClientLike ? true : false>;
168
+
169
+ /**
170
+ * The installed declaration must expose the import surface with a payload
171
+ * shape compatible with `SessionImportCallInput` and a top-level `location`
172
+ * target field. This pins the declaration side of the known
173
+ * `SessionDomain`-vs-generated-client mismatch to the narrowest typed
174
+ * boundary; runtime callability is still checked by the probe.
175
+ */
176
+ type _AssertHostImportClientCompatible = AssertTrue<HostTuiClient extends SessionImportClientLike ? true : false>;
177
+ type _AssertHostImportInputCompatible = AssertTrue<
178
+ Parameters<HostTuiClient["session"]["import"]>[0] extends SessionImportCallInput ? true : false
179
+ >;
180
+ type _AssertHostImportInputHasLocationTarget = AssertTrue<
181
+ Parameters<HostTuiClient["session"]["import"]>[0] extends { location?: { directory: string } | null | undefined }
182
+ ? true
183
+ : false
184
+ >;
185
+
186
+ /**
187
+ * The declared `session.import` output must keep exposing the fields the
188
+ * adapter validates (`id`, `projectID`, `location.directory`); drift in those
189
+ * fields is a compile error, not a silent runtime fallback. The declaration
190
+ * type is the unwrapped `SessionInfo` (the raw `{ data }` envelope is already
191
+ * unwrapped by the generated promise client), which is exactly what the
192
+ * runtime guard in `session-import.ts` re-proves.
193
+ */
194
+ type HostImportOutput = Awaited<ReturnType<HostTuiClient["session"]["import"]>>;
195
+ type _AssertHostImportOutputHasValidatedFields = AssertTrue<
196
+ HostImportOutput extends {
197
+ readonly id: string;
198
+ readonly projectID: string;
199
+ readonly location: { readonly directory: string };
200
+ }
201
+ ? true
202
+ : false
203
+ >;
204
+
205
+ /* ------------------------------------------------------------------------- */
206
+ /* Runtime resolution helpers */
207
+ /* ------------------------------------------------------------------------- */
208
+
209
+ /** True for non-null, non-array object values; used on untrusted client values. */
210
+ export function isObjectRecord(value: unknown): value is { readonly [key: string]: unknown } {
211
+ return typeof value === "object" && value !== null && !Array.isArray(value);
212
+ }
213
+
214
+ type UnaryMethod<Input> = (input: Input) => Promise<unknown>;
215
+
216
+ /**
217
+ * Resolves `client[container][method]` to a typed call signature only when the
218
+ * value is actually a function. This is the single unknown-to-typed conversion
219
+ * boundary: the `typeof` check proves callability, and the returned wrapper
220
+ * awaits whatever the host method returns so the result is always a Promise.
221
+ *
222
+ * The wrapper preserves the exact owning object (`client[container]`) as the
223
+ * receiver via `Reflect.apply`, so a host method that depends on `this` (class
224
+ * method, prototype method, non-arrow object property) is invoked with the
225
+ * same receiver a direct `client[container][method](input)` call would supply.
226
+ * The installed v2.0.22 generated promise client happens to build these
227
+ * methods as arrow closures over its transport (which ignore the receiver),
228
+ * but the adapter must not depend on that implementation detail.
229
+ */
230
+ export function resolveUnaryMethod<Input>(client: unknown, container: string, method: string): UnaryMethod<Input> | undefined {
231
+ if (!isObjectRecord(client)) return undefined;
232
+ const owner = client[container];
233
+ if (!isObjectRecord(owner)) return undefined;
234
+ const candidate = owner[method];
235
+ if (typeof candidate !== "function") return undefined;
236
+ const callable = candidate as (...args: unknown[]) => unknown;
237
+ return async (input: Input) => Reflect.apply(callable, owner, [input]);
238
+ }
239
+
240
+ /** Requires a non-empty string or fails closed with `invalid-request`. */
241
+ export function requireNonEmptyString(value: unknown, label: string): string {
242
+ if (typeof value !== "string" || value.length === 0) {
243
+ throw new HostAdapterError("invalid-request", `${label} must be a non-empty string`);
244
+ }
245
+ return value;
246
+ }
247
+
248
+ /** Requires a positive safe integer or fails closed with `invalid-request`. */
249
+ export function requirePositiveSafeInteger(value: unknown, label: string): number {
250
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
251
+ throw new HostAdapterError("invalid-request", `${label} must be a positive safe integer`);
252
+ }
253
+ return value;
254
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * SessionPort V1 TUI host adapters (Phase 4, non-visual lane).
3
+ *
4
+ * Public surface for orchestration integration:
5
+ * - `createSessionReadAdapter(client)` → core `SessionReadPort` backed by
6
+ * `context.client.session.list` / `context.client.message.list`.
7
+ * - `probeSessionImportCapability(client)` → O-12 restore-start capability
8
+ * probe (never throws).
9
+ * - `createSessionImportAdapter(client)` → single-session import adapter that
10
+ * always supplies the caller's explicit `{ directory }` target (O-16) and
11
+ * fail-closed validates the unwrapped host response before treating an
12
+ * import as confirmed (`invalid-response`; see `SessionImportResult`).
13
+ * - `createProjectDestinationsAdapter(client)` → restore destination
14
+ * candidates from `context.client.project.list` as declaration-derived
15
+ * `{ projectID, targetDirectory, label }` pairs; the pairs are not
16
+ * runtime-verified and require runtime pair validation before restore use
17
+ * (OQ-16 stays OPEN).
18
+ * - `createSessionIdInventory(input)` → bounded metadata-only cross-project
19
+ * session-ID inventory over the validated project-list candidates and an
20
+ * injected `SessionReadPort` (only `session.list` is called; no message,
21
+ * location, or import surface is touched). The returned IDs are occupancy
22
+ * evidence only (2026-10-14 owner-approved collision amendment).
23
+ * - `createLocationResolver(client)` → experimental explicit-directory project
24
+ * resolution through `context.client.location.get` with exact, non-blank
25
+ * response matching and fail-closed absence/mismatch handling; `location.get`
26
+ * may persist project state as one deliberate, bounded side effect (E42;
27
+ * OQ-16 stays OPEN).
28
+ *
29
+ * All client-facing factories accept the host-provided client as an untrusted
30
+ * `unknown` value; see `host-client.ts` for the narrow typed boundary, runtime
31
+ * resolution, and the compile-time assertions against the installed v2.0.22
32
+ * TUI client declaration. `createSessionIdInventory` resolves no client surface
33
+ * at all: it consumes the already-validated candidates plus an injected
34
+ * `SessionReadPort`. No host types are imported at runtime and no host calls
35
+ * are made by this module.
36
+ */
37
+
38
+ export { HostAdapterError } from "./host-client.ts";
39
+ export type {
40
+ HostAdapterErrorCode,
41
+ MessageListCallInput,
42
+ SessionImportCallInput,
43
+ SessionImportResult,
44
+ SessionListCallInput,
45
+ } from "./host-client.ts";
46
+ export { createLocationResolver, LocationResolutionError } from "./location-resolver.ts";
47
+ export type {
48
+ LocationGetCallInput,
49
+ LocationResolutionErrorCode,
50
+ LocationResolver,
51
+ ResolvedProjectLocation,
52
+ SelectedProjectCandidate,
53
+ } from "./location-resolver.ts";
54
+ export {
55
+ createProjectDestinationsAdapter,
56
+ mapProjectListResponse,
57
+ ProjectDestinationError,
58
+ } from "./project-destinations.ts";
59
+ export type {
60
+ ProjectDestinationCandidate,
61
+ ProjectDestinationErrorCode,
62
+ ProjectDestinationsAdapter,
63
+ } from "./project-destinations.ts";
64
+ export { createSessionIdInventory } from "./session-id-inventory.ts";
65
+ export type {
66
+ SessionIdInventory,
67
+ SessionIdInventoryInput,
68
+ SessionIdInventoryProject,
69
+ } from "./session-id-inventory.ts";
70
+ export { createSessionReadAdapter, mapMessageListRequest, mapSessionListRequest } from "./session-read.ts";
71
+ export { buildSessionImportCall, createSessionImportAdapter, probeSessionImportCapability } from "./session-import.ts";
72
+ export type { SessionImportAdapter, SessionImportProbe, SessionImportRequest } from "./session-import.ts";
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Experimental project-location resolver over the public TUI client
3
+ * `context.client.location.get` method (Windows-first experimental lane).
4
+ *
5
+ * Scope and boundaries (owner decision 2026-10-11, decision-log F):
6
+ * - `Plugin.Context.location` carries only a directory, so the experimental
7
+ * checkpoint path resolves that active directory through public
8
+ * `location.get({ location: { directory } })`; restore calls `location.get`
9
+ * only for the user-selected candidate (never speculatively, never to
10
+ * enumerate candidates).
11
+ * - Responses are validated fail-closed: the returned `directory`, the
12
+ * `project.directory`, and the `project.id` must be present, non-blank, and
13
+ * exactly equal to the requested values. Absent/malformed/mismatched
14
+ * responses reject before any artifact read, decrypt, write, or import.
15
+ * - E42 observed that `location.get` can persist/upsert project state in the
16
+ * host store; that is a deliberate, bounded side effect of this one explicit
17
+ * call per flow (checkpoint: active directory; restore: selected candidate).
18
+ * OQ-16 remains OPEN for general/current-project and cross-runtime mapping,
19
+ * and no project-ID algorithm, stability, or canonical-path generalization
20
+ * is claimed.
21
+ * - Host/API errors propagate unchanged (never wrapped, retried, or
22
+ * swallowed); this module adds no retry and no fallback resolution.
23
+ *
24
+ * Method invocation follows the `host-client.ts` resolver convention:
25
+ * `resolveUnaryMethod` resolves `client.location.get` and invokes it with the
26
+ * owning `client.location` object preserved as the receiver.
27
+ *
28
+ * Evidence boundary: pinned v2.0.22 declaration assertions plus synthetic
29
+ * fake-client tests only. No live host call is made by this module, and no
30
+ * cross-version, compatibility, or support claim follows.
31
+ */
32
+
33
+ import type { Plugin } from "@opencode/plugin/tui";
34
+ import { isObjectRecord, resolveUnaryMethod } from "./host-client.ts";
35
+
36
+ /* ------------------------------------------------------------------------- */
37
+ /* Errors */
38
+ /* ------------------------------------------------------------------------- */
39
+
40
+ /** Stable machine-readable failure codes for project-location resolution. */
41
+ export type LocationResolutionErrorCode =
42
+ /** The installed client does not expose a callable `location.get`. */
43
+ | "capability-unavailable"
44
+ /** The caller supplied a blank or missing explicit directory/project ID. */
45
+ | "invalid-request"
46
+ /** The host response did not carry the declared fields as non-blank strings. */
47
+ | "invalid-response"
48
+ /** The host response did not exactly match the requested directory/project ID. */
49
+ | "mismatch";
50
+
51
+ /** Fail-closed resolution error; host API errors are never wrapped in it. */
52
+ export class LocationResolutionError extends Error {
53
+ readonly code: LocationResolutionErrorCode;
54
+
55
+ constructor(code: LocationResolutionErrorCode, message: string, options?: ErrorOptions) {
56
+ super(message, options);
57
+ this.name = "LocationResolutionError";
58
+ this.code = code;
59
+ }
60
+ }
61
+
62
+ /* ------------------------------------------------------------------------- */
63
+ /* Narrow client surface (documentation + compile-time assertions) */
64
+ /* ------------------------------------------------------------------------- */
65
+
66
+ /** One `location.get` request as sent by this adapter. */
67
+ export interface LocationGetCallInput {
68
+ readonly location: { readonly directory: string };
69
+ }
70
+
71
+ /**
72
+ * Narrow structural view of the public location surface. `location` and `get`
73
+ * are optional because capability absence is a real runtime state; the factory
74
+ * fails closed with a clear `LocationResolutionError` when the method is
75
+ * missing or not callable.
76
+ */
77
+ export interface LocationGetClientLike {
78
+ readonly location?: {
79
+ get?(input: LocationGetCallInput): Promise<unknown>;
80
+ };
81
+ }
82
+
83
+ type HostTuiClient = Plugin.Context["client"];
84
+ type AssertTrue<T extends true> = T;
85
+
86
+ /**
87
+ * The installed v2.0.22 TUI client must satisfy the narrow `location.get`
88
+ * surface; if a future pinned declaration drifts, `tsc` fails here instead of
89
+ * failing at runtime.
90
+ */
91
+ type _AssertHostLocationGetClientCompatible = AssertTrue<HostTuiClient extends LocationGetClientLike ? true : false>;
92
+
93
+ /**
94
+ * The declared `location.get` output must keep exposing the fields this
95
+ * adapter validates (`directory`, `project.id`, `project.directory`). Drift in
96
+ * those fields is a compile error, not a silent runtime fallback.
97
+ */
98
+ type HostLocationGetOutput = Awaited<ReturnType<HostTuiClient["location"]["get"]>>;
99
+ type _AssertHostLocationGetOutputHasFields = AssertTrue<
100
+ HostLocationGetOutput extends {
101
+ readonly directory: string;
102
+ readonly project: { readonly id: string; readonly directory: string };
103
+ }
104
+ ? true
105
+ : false
106
+ >;
107
+
108
+ /* ------------------------------------------------------------------------- */
109
+ /* Public types */
110
+ /* ------------------------------------------------------------------------- */
111
+
112
+ /**
113
+ * One exact, verified project location. `directory` is the requested directory
114
+ * that the host response confirmed verbatim; `projectID` is the host-returned
115
+ * project ID (for the selected-candidate flow it is also required to equal the
116
+ * candidate's declared project ID).
117
+ */
118
+ export interface ResolvedProjectLocation {
119
+ readonly projectID: string;
120
+ readonly directory: string;
121
+ }
122
+
123
+ /** One selected destination candidate to verify against the host. */
124
+ export interface SelectedProjectCandidate {
125
+ readonly projectID: string;
126
+ readonly targetDirectory: string;
127
+ }
128
+
129
+ /** Location-resolution surface. One `location.get` call per resolution; no caching. */
130
+ export interface LocationResolver {
131
+ /**
132
+ * Resolves the active project for an explicit directory via
133
+ * `location.get({ location: { directory } })`. Requires the response
134
+ * `directory` and `project.directory` to equal the requested directory and
135
+ * `project.id` to be a non-blank string.
136
+ */
137
+ resolveActiveProject(directory: string): Promise<ResolvedProjectLocation>;
138
+
139
+ /**
140
+ * Resolves one user-selected candidate via
141
+ * `location.get({ location: { directory: candidate.targetDirectory } })`.
142
+ * Requires the response `directory` and `project.directory` to equal the
143
+ * candidate directory and `project.id` to equal the candidate project ID.
144
+ */
145
+ resolveSelectedProject(candidate: SelectedProjectCandidate): Promise<ResolvedProjectLocation>;
146
+ }
147
+
148
+ /* ------------------------------------------------------------------------- */
149
+ /* Factory */
150
+ /* ------------------------------------------------------------------------- */
151
+
152
+ /**
153
+ * Creates the location resolver. Fails closed with
154
+ * `LocationResolutionError` (`capability-unavailable`) when `location.get` is
155
+ * absent or not callable, before any host call.
156
+ */
157
+ export function createLocationResolver(client: unknown): LocationResolver {
158
+ const locationGet = resolveUnaryMethod<LocationGetCallInput>(client, "location", "get");
159
+ if (locationGet === undefined) {
160
+ throw new LocationResolutionError(
161
+ "capability-unavailable",
162
+ "The installed TUI client does not expose a callable location.get; cannot resolve a project",
163
+ );
164
+ }
165
+ return {
166
+ async resolveActiveProject(directory: string): Promise<ResolvedProjectLocation> {
167
+ const requestedDirectory = requireExplicitDirectory(directory, "active project directory");
168
+ const response = await locationGet({ location: { directory: requestedDirectory } });
169
+ return validateResolution(response, requestedDirectory, undefined);
170
+ },
171
+ async resolveSelectedProject(candidate: SelectedProjectCandidate): Promise<ResolvedProjectLocation> {
172
+ if (!isObjectRecord(candidate)) {
173
+ throw new LocationResolutionError(
174
+ "invalid-request",
175
+ "resolveSelectedProject requires a candidate object with projectID and targetDirectory",
176
+ );
177
+ }
178
+ const requestedProjectID = requireNonBlankString(candidate.projectID, "candidate project ID");
179
+ const requestedDirectory = requireExplicitDirectory(candidate.targetDirectory, "candidate target directory");
180
+ const response = await locationGet({ location: { directory: requestedDirectory } });
181
+ return validateResolution(response, requestedDirectory, requestedProjectID);
182
+ },
183
+ };
184
+ }
185
+
186
+ /* ------------------------------------------------------------------------- */
187
+ /* Response validation (fail closed, no data in error messages) */
188
+ /* ------------------------------------------------------------------------- */
189
+
190
+ /**
191
+ * Validates one `location.get` response against the requested directory and,
192
+ * for the selected-candidate flow, the requested project ID. Returns only
193
+ * exactly verified values; any absence, malformed field, or mismatch rejects.
194
+ * Error messages carry field labels and value types only — never directory
195
+ * paths, project IDs, or raw host data.
196
+ */
197
+ function validateResolution(
198
+ response: unknown,
199
+ requestedDirectory: string,
200
+ expectedProjectID: string | undefined,
201
+ ): ResolvedProjectLocation {
202
+ if (!isObjectRecord(response)) {
203
+ throw new LocationResolutionError(
204
+ "invalid-response",
205
+ `location.get did not return an object; received ${describeValue(response)}`,
206
+ );
207
+ }
208
+ const responseDirectory = response["directory"];
209
+ if (typeof responseDirectory !== "string" || responseDirectory.trim().length === 0) {
210
+ throw new LocationResolutionError(
211
+ "invalid-response",
212
+ `location.get response must carry a non-blank string directory; received ${describeValue(responseDirectory)}`,
213
+ );
214
+ }
215
+ if (responseDirectory !== requestedDirectory) {
216
+ throw new LocationResolutionError(
217
+ "mismatch",
218
+ "location.get returned a directory that does not exactly match the requested directory",
219
+ );
220
+ }
221
+ const project = response["project"];
222
+ if (!isObjectRecord(project)) {
223
+ throw new LocationResolutionError(
224
+ "invalid-response",
225
+ `location.get response must carry a project object; received ${describeValue(project)}`,
226
+ );
227
+ }
228
+ const projectID = project["id"];
229
+ if (typeof projectID !== "string" || projectID.trim().length === 0) {
230
+ throw new LocationResolutionError(
231
+ "invalid-response",
232
+ `location.get response project must carry a non-blank string id; received ${describeValue(projectID)}`,
233
+ );
234
+ }
235
+ if (expectedProjectID !== undefined && projectID !== expectedProjectID) {
236
+ throw new LocationResolutionError(
237
+ "mismatch",
238
+ "location.get returned a project ID that does not exactly match the selected candidate",
239
+ );
240
+ }
241
+ const projectDirectory = project["directory"];
242
+ if (typeof projectDirectory !== "string" || projectDirectory.trim().length === 0) {
243
+ throw new LocationResolutionError(
244
+ "invalid-response",
245
+ `location.get response project must carry a non-blank string directory; received ${describeValue(projectDirectory)}`,
246
+ );
247
+ }
248
+ if (projectDirectory !== requestedDirectory) {
249
+ throw new LocationResolutionError(
250
+ "mismatch",
251
+ "location.get returned a project directory that does not exactly match the requested directory",
252
+ );
253
+ }
254
+ return { projectID, directory: requestedDirectory };
255
+ }
256
+
257
+ /** Requires an explicit non-blank directory; the value is returned verbatim. */
258
+ function requireExplicitDirectory(value: unknown, label: string): string {
259
+ if (typeof value !== "string" || value.trim().length === 0) {
260
+ throw new LocationResolutionError(
261
+ "invalid-request",
262
+ `${label} must be an explicit non-blank string; no default is applied`,
263
+ );
264
+ }
265
+ return value;
266
+ }
267
+
268
+ /** Requires a non-blank string; the value is returned verbatim (never trimmed). */
269
+ function requireNonBlankString(value: unknown, label: string): string {
270
+ if (typeof value !== "string" || value.trim().length === 0) {
271
+ throw new LocationResolutionError("invalid-request", `${label} must be a non-blank string`);
272
+ }
273
+ return value;
274
+ }
275
+
276
+ /** Type-only description for error messages; never includes the value itself. */
277
+ function describeValue(value: unknown): string {
278
+ if (value === null) return "null";
279
+ if (Array.isArray(value)) return "array";
280
+ return typeof value;
281
+ }