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.
- package/LICENSE +21 -0
- package/README.md +84 -2
- package/package.json +56 -4
- package/src/core/contracts/crypto.ts +66 -0
- package/src/core/contracts/index.ts +13 -0
- package/src/core/contracts/json.ts +142 -0
- package/src/core/contracts/session.ts +12 -0
- package/src/core/crypto/container.ts +686 -0
- package/src/core/crypto/envelope.ts +232 -0
- package/src/core/crypto/errors.ts +52 -0
- package/src/core/crypto/gzip-stream.ts +388 -0
- package/src/core/crypto/index.ts +13 -0
- package/src/core/crypto/kdf.ts +101 -0
- package/src/core/crypto/policy.ts +202 -0
- package/src/core/crypto/zero.ts +33 -0
- package/src/core/restore/identity.ts +202 -0
- package/src/core/restore/index.ts +24 -0
- package/src/core/restore/planner.ts +1122 -0
- package/src/core/sessions/collector.ts +323 -0
- package/src/core/sessions/errors.ts +37 -0
- package/src/core/sessions/guards.ts +11 -0
- package/src/core/sessions/index.ts +29 -0
- package/src/core/sessions/policy.ts +80 -0
- package/src/core/sessions/port.ts +88 -0
- package/src/core/sessions/serialize.ts +15 -0
- package/src/core/sessions/unsettled.ts +35 -0
- package/src/integrations/omo/index.ts +28 -0
- package/src/tui/adapters/host-client.ts +254 -0
- package/src/tui/adapters/index.ts +72 -0
- package/src/tui/adapters/location-resolver.ts +281 -0
- package/src/tui/adapters/project-destinations.ts +271 -0
- package/src/tui/adapters/session-id-inventory.ts +378 -0
- package/src/tui/adapters/session-import.ts +189 -0
- package/src/tui/adapters/session-read.ts +115 -0
- package/src/tui/artifacts/files.ts +285 -0
- package/src/tui/orchestration/checkpoint.ts +160 -0
- package/src/tui/orchestration/index.ts +55 -0
- package/src/tui/orchestration/restore-auth.ts +94 -0
- package/src/tui/orchestration/restore-execution.ts +381 -0
- package/src/tui/orchestration/restore-preflight.ts +223 -0
- package/src/tui/orchestration/shared.ts +75 -0
- package/src/tui/ui/password-dialog.tsx +251 -0
- package/src/tui/ui/password-flow.ts +288 -0
- package/src/tui/ui/restore-project-selector.ts +62 -0
- package/src/tui/ui/restore-report-dialog.tsx +251 -0
- package/src/tui/ui/restore-report-flow.ts +94 -0
- package/src/tui/ui/sessionport-commands.tsx +65 -0
- package/src/tui/wiring/composition.ts +354 -0
- package/src/tui/wiring/experimental-profile.ts +86 -0
- package/src/tui/wiring/index.ts +51 -0
- package/src/tui/wiring/install.ts +108 -0
- package/src/tui/wiring/reports.ts +340 -0
- package/src/tui.ts +34 -0
- package/tsconfig.json +18 -0
- 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
|
+
}
|