opencode-session-port 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +84 -2
  3. package/dist/tui.js +10276 -0
  4. package/package.json +48 -4
  5. package/src/core/contracts/crypto.ts +66 -0
  6. package/src/core/contracts/index.ts +13 -0
  7. package/src/core/contracts/json.ts +142 -0
  8. package/src/core/contracts/session.ts +12 -0
  9. package/src/core/crypto/container.ts +686 -0
  10. package/src/core/crypto/envelope.ts +232 -0
  11. package/src/core/crypto/errors.ts +52 -0
  12. package/src/core/crypto/gzip-stream.ts +388 -0
  13. package/src/core/crypto/index.ts +13 -0
  14. package/src/core/crypto/kdf.ts +101 -0
  15. package/src/core/crypto/policy.ts +202 -0
  16. package/src/core/crypto/zero.ts +33 -0
  17. package/src/core/restore/identity.ts +202 -0
  18. package/src/core/restore/index.ts +24 -0
  19. package/src/core/restore/planner.ts +1122 -0
  20. package/src/core/sessions/collector.ts +323 -0
  21. package/src/core/sessions/errors.ts +37 -0
  22. package/src/core/sessions/guards.ts +11 -0
  23. package/src/core/sessions/index.ts +29 -0
  24. package/src/core/sessions/policy.ts +80 -0
  25. package/src/core/sessions/port.ts +88 -0
  26. package/src/core/sessions/serialize.ts +15 -0
  27. package/src/core/sessions/unsettled.ts +35 -0
  28. package/src/integrations/omo/index.ts +28 -0
  29. package/src/tui/adapters/host-client.ts +254 -0
  30. package/src/tui/adapters/index.ts +72 -0
  31. package/src/tui/adapters/location-resolver.ts +281 -0
  32. package/src/tui/adapters/project-destinations.ts +271 -0
  33. package/src/tui/adapters/session-id-inventory.ts +378 -0
  34. package/src/tui/adapters/session-import.ts +189 -0
  35. package/src/tui/adapters/session-read.ts +115 -0
  36. package/src/tui/artifacts/files.ts +285 -0
  37. package/src/tui/orchestration/checkpoint.ts +160 -0
  38. package/src/tui/orchestration/index.ts +55 -0
  39. package/src/tui/orchestration/restore-auth.ts +94 -0
  40. package/src/tui/orchestration/restore-execution.ts +381 -0
  41. package/src/tui/orchestration/restore-preflight.ts +223 -0
  42. package/src/tui/orchestration/shared.ts +75 -0
  43. package/src/tui/ui/password-dialog.tsx +251 -0
  44. package/src/tui/ui/password-flow.ts +288 -0
  45. package/src/tui/ui/restore-project-selector.ts +62 -0
  46. package/src/tui/ui/restore-report-dialog.tsx +251 -0
  47. package/src/tui/ui/restore-report-flow.ts +94 -0
  48. package/src/tui/ui/sessionport-commands.tsx +65 -0
  49. package/src/tui/wiring/composition.ts +354 -0
  50. package/src/tui/wiring/experimental-profile.ts +86 -0
  51. package/src/tui/wiring/index.ts +51 -0
  52. package/src/tui/wiring/install.ts +108 -0
  53. package/src/tui/wiring/reports.ts +340 -0
  54. package/src/tui.ts +34 -0
  55. package/tsconfig.json +18 -0
  56. package/tui.ts +17 -0
@@ -0,0 +1,323 @@
1
+ import {
2
+ snapshotJsonValue,
3
+ type CheckpointPayload,
4
+ type JsonObject,
5
+ type JsonValue,
6
+ type SessionRecord,
7
+ } from "../contracts/index.ts";
8
+ import { SessionCollectionError, type SessionCollectionErrorCode } from "./errors.ts";
9
+ import { errorText, isJsonObject } from "./guards.ts";
10
+ import { validateSessionCollectionPolicy, type SessionCollectionPolicy } from "./policy.ts";
11
+ import type { MessageListPageRequest, SessionListPageRequest, SessionReadPort } from "./port.ts";
12
+ import { findUnsettledMessageReason } from "./unsettled.ts";
13
+
14
+ /** Input for one checkpoint payload collection. The project and every bound are explicit. */
15
+ export interface CollectCheckpointPayloadRequest {
16
+ /** Explicit project scope; current-project resolution is out of scope (OQ-10). */
17
+ readonly projectID: string;
18
+ /** Explicit local limits; no defaults are applied. */
19
+ readonly policy: SessionCollectionPolicy;
20
+ }
21
+
22
+ /**
23
+ * Collects a complete `CheckpointPayload` for one explicit project by paging
24
+ * the session list and each session's message list to exhaustion.
25
+ *
26
+ * Behavior (v2.0.22 declarations/source research, E13–E15):
27
+ * - Follows `cursor.next` until it is absent or `null`; page size always comes
28
+ * from the explicit policy.
29
+ * - Includes every session the API returns. Archived sessions are not
30
+ * filtered and child links are not inferred; child-association semantics and
31
+ * legacy fork semantics remain OPEN (OQ-10).
32
+ * - First message page requests `order: "asc"`; subsequent pages pass only the
33
+ * opaque cursor (the API cannot combine `order` with `cursor`). No `type`
34
+ * filter is ever sent, and `session.export` is never used because it drops
35
+ * unsettled entries silently (E15).
36
+ * - Every item is validated into a detached JSON-only snapshot via the shared
37
+ * `snapshotJsonValue` contract; opaque `info`/message fields and all
38
+ * IDs/references are preserved unchanged (no canonicalization or remapping
39
+ * here — O-04/O-05 are OPEN).
40
+ * - Returned sessions are bound to the requested scope fail-closed: every
41
+ * session from the list must carry a present `projectID` exactly equal to
42
+ * the requested project; missing or mismatched values reject the whole
43
+ * collection and out-of-scope sessions are neither filtered out nor
44
+ * included.
45
+ * - Message association is endpoint-scoped instead of per-record: the
46
+ * collector addresses each session's messages through
47
+ * `listMessages(sessionID, ...)`, and the v2.0.22 route
48
+ * `GET /api/session/:sessionID/message` scopes returned records to that path
49
+ * session. v2.0.22 `Session.Message.Info` carries no per-message
50
+ * `sessionID` (its common Base is `id`/metadata/`time`), so each message
51
+ * record cannot independently assert its owning session. This collector
52
+ * therefore does not require, validate, normalize, or invent a per-message
53
+ * `sessionID`; message records are retained verbatim and unknown fields
54
+ * (including any future `sessionID`-shaped field) stay opaque.
55
+ * - The `maxTotalMessages` bound spans every session and every message page:
56
+ * before a page is accumulated, the number already collected plus that whole
57
+ * page must not exceed the bound, or the entire collection fails with
58
+ * `total-message-limit-exceeded`. A page that would push the collection over
59
+ * the bound is rejected, never truncated.
60
+ * - Any unsettled message (assistant without `time.completed`; running
61
+ * shell/compaction), duplicate session/message id, repeated cursor, API
62
+ * failure, malformed page/record, session scope-binding violation (missing
63
+ * or mismatched session `projectID`), or exceeded bound fails the whole
64
+ * collection. It never returns a partial payload or truncated history.
65
+ *
66
+ * Caveat (O-21 / OQ-23): the upstream list APIs document no snapshot
67
+ * consistency, so this is a best-effort page sequence, not an atomic snapshot,
68
+ * and it defines no retry policy. This function is one step of a checkpoint,
69
+ * not a complete checkpoint transaction; compression, encryption, and artifact
70
+ * writing are separate concerns.
71
+ */
72
+ export async function collectCheckpointPayload(
73
+ port: SessionReadPort,
74
+ input: CollectCheckpointPayloadRequest,
75
+ ): Promise<CheckpointPayload> {
76
+ if (!isJsonObject(input)) {
77
+ throw new SessionCollectionError(
78
+ "invalid-request",
79
+ "collectCheckpointPayload requires an input object with an explicit projectID and SessionCollectionPolicy",
80
+ );
81
+ }
82
+ if (typeof port?.listSessions !== "function" || typeof port?.listMessages !== "function") {
83
+ throw new SessionCollectionError("invalid-request", "SessionReadPort must provide listSessions and listMessages");
84
+ }
85
+ validateSessionCollectionPolicy(input.policy);
86
+ const projectID = input.projectID;
87
+ if (typeof projectID !== "string" || projectID.length === 0) {
88
+ throw new SessionCollectionError(
89
+ "invalid-request",
90
+ "collectCheckpointPayload requires an explicit non-empty projectID string; current-project resolution is out of scope (OQ-10)",
91
+ );
92
+ }
93
+
94
+ const sessions: SessionRecord[] = [];
95
+ const sessionIds = new Set<string>();
96
+ const usedSessionCursors = new Set<string>();
97
+ let sessionCursor: string | undefined;
98
+ /** Messages accumulated across every session and page so far; enforced against `maxTotalMessages`. */
99
+ let collectedMessageCount = 0;
100
+
101
+ for (;;) {
102
+ const pageRequest: SessionListPageRequest =
103
+ sessionCursor === undefined
104
+ ? { project: projectID, limit: input.policy.sessionPageSize }
105
+ : { project: projectID, limit: input.policy.sessionPageSize, cursor: sessionCursor };
106
+ const label = `Session list for project ${JSON.stringify(projectID)}`;
107
+ const rawPage = await callPort(() => port.listSessions(pageRequest), "session-list-failed", `${label} request failed`);
108
+ const page = parsePageEnvelope(rawPage, "malformed-session-page", `${label} response`);
109
+
110
+ for (const rawSession of page.items) {
111
+ const info = snapshotJsonRecord(rawSession, "malformed-session-record", `Session list item ${sessions.length}`);
112
+ const id = info["id"];
113
+ if (typeof id !== "string" || id.length === 0) {
114
+ throw new SessionCollectionError("malformed-session-record", "Session list item did not contain a non-empty string id");
115
+ }
116
+ const boundProjectID = info["projectID"];
117
+ if (typeof boundProjectID !== "string" || boundProjectID !== projectID) {
118
+ throw new SessionCollectionError(
119
+ "session-project-mismatch",
120
+ `${label} returned session ${JSON.stringify(id)} with ${
121
+ boundProjectID === undefined ? "no projectID" : `projectID ${JSON.stringify(boundProjectID)}`
122
+ }; refusing to include a session outside the requested project scope`,
123
+ );
124
+ }
125
+ if (sessionIds.has(id)) {
126
+ throw new SessionCollectionError(
127
+ "duplicate-session-id",
128
+ `Session list returned session id ${JSON.stringify(id)} more than once; refusing to continue`,
129
+ );
130
+ }
131
+ if (sessions.length >= input.policy.maxSessions) {
132
+ throw new SessionCollectionError(
133
+ "session-limit-exceeded",
134
+ `Session list exceeded SessionCollectionPolicy.maxSessions=${input.policy.maxSessions}; refusing to emit a partial checkpoint`,
135
+ );
136
+ }
137
+ sessionIds.add(id);
138
+ const messages = await collectSessionMessages(port, id, input.policy, collectedMessageCount);
139
+ collectedMessageCount += messages.length;
140
+ sessions.push({ info, messages });
141
+ }
142
+
143
+ if (page.next === undefined) break;
144
+ if (usedSessionCursors.has(page.next)) {
145
+ throw new SessionCollectionError(
146
+ "repeated-session-cursor",
147
+ `Session list returned a cursor that was already used: ${JSON.stringify(page.next)}; refusing to loop`,
148
+ );
149
+ }
150
+ usedSessionCursors.add(page.next);
151
+ sessionCursor = page.next;
152
+ }
153
+
154
+ return { sessions };
155
+ }
156
+
157
+ /**
158
+ * Pages one session's messages to exhaustion in server order.
159
+ *
160
+ * The first request is explicitly ascending; every later request carries only
161
+ * the opaque cursor (the API cannot combine `order` with `cursor`). The
162
+ * `sessionID` in the request is the ownership boundary: the v2.0.22 route
163
+ * scopes records to its `:sessionID` path parameter, and message records
164
+ * carry no `sessionID` of their own, so association is endpoint-scoped and is
165
+ * deliberately not re-asserted or normalized per record. Duplicate message
166
+ * ids, repeated cursors, malformed records, unsettled messages, API failures,
167
+ * the per-session bound, and the cross-session `maxTotalMessages` bound all
168
+ * fail closed. The total bound is checked against
169
+ * `alreadyCollectedMessages + messages collected so far in this session + whole
170
+ * page size` before the page is accumulated: a page that would push the
171
+ * collection over the bound rejects the whole collection instead of being
172
+ * truncated.
173
+ */
174
+ async function collectSessionMessages(
175
+ port: SessionReadPort,
176
+ sessionID: string,
177
+ policy: SessionCollectionPolicy,
178
+ alreadyCollectedMessages: number,
179
+ ): Promise<readonly JsonObject[]> {
180
+ const messages: JsonObject[] = [];
181
+ const messageIds = new Set<string>();
182
+ const usedCursors = new Set<string>();
183
+ let cursor: string | undefined;
184
+
185
+ for (;;) {
186
+ const pageRequest: MessageListPageRequest =
187
+ cursor === undefined
188
+ ? { sessionID, limit: policy.messagePageSize, order: "asc" }
189
+ : { sessionID, limit: policy.messagePageSize, cursor };
190
+ const label = `Message list for session ${JSON.stringify(sessionID)}`;
191
+ const rawPage = await callPort(() => port.listMessages(pageRequest), "message-list-failed", `${label} request failed`);
192
+ const page = parsePageEnvelope(rawPage, "malformed-message-page", `${label} response`);
193
+
194
+ // Fail closed before accumulating this page when the collection would
195
+ // exceed the cross-session total. The page is counted whole (every item
196
+ // either becomes a message or rejects the collection), so a partially
197
+ // fitting page is never truncated.
198
+ const pageTotal = alreadyCollectedMessages + messages.length + page.items.length;
199
+ if (pageTotal > policy.maxTotalMessages) {
200
+ throw new SessionCollectionError(
201
+ "total-message-limit-exceeded",
202
+ `${label} would bring the total message count to ${pageTotal}, exceeding SessionCollectionPolicy.maxTotalMessages=${policy.maxTotalMessages}; refusing to emit a partial checkpoint`,
203
+ );
204
+ }
205
+
206
+ for (const rawMessage of page.items) {
207
+ const message = snapshotJsonRecord(rawMessage, "malformed-message-record", `${label} item ${messages.length}`);
208
+ const id = message["id"];
209
+ if (typeof id !== "string" || id.length === 0) {
210
+ throw new SessionCollectionError("malformed-message-record", `${label} returned a message without a non-empty string id`);
211
+ }
212
+ if (messageIds.has(id)) {
213
+ throw new SessionCollectionError(
214
+ "duplicate-message-id",
215
+ `${label} returned message id ${JSON.stringify(id)} more than once; refusing to continue`,
216
+ );
217
+ }
218
+ if (messages.length >= policy.maxMessagesPerSession) {
219
+ throw new SessionCollectionError(
220
+ "message-limit-exceeded",
221
+ `${label} exceeded SessionCollectionPolicy.maxMessagesPerSession=${policy.maxMessagesPerSession}; refusing to emit a partial checkpoint`,
222
+ );
223
+ }
224
+ messageIds.add(id);
225
+ const unsettled = findUnsettledMessageReason(message);
226
+ if (unsettled !== undefined) {
227
+ throw new SessionCollectionError(
228
+ "unsettled-message",
229
+ `${label} message ${JSON.stringify(id)} is unsettled (${unsettled}); official transfer/import would drop it, so the checkpoint fails closed with no artifact`,
230
+ );
231
+ }
232
+ messages.push(message);
233
+ }
234
+
235
+ if (page.next === undefined) break;
236
+ if (usedCursors.has(page.next)) {
237
+ throw new SessionCollectionError(
238
+ "repeated-message-cursor",
239
+ `${label} returned a cursor that was already used: ${JSON.stringify(page.next)}; refusing to loop`,
240
+ );
241
+ }
242
+ usedCursors.add(page.next);
243
+ cursor = page.next;
244
+ }
245
+
246
+ return messages;
247
+ }
248
+
249
+ interface ParsedPage {
250
+ readonly items: readonly unknown[];
251
+ /** `cursor.next` when present and non-null; `undefined` means the page sequence is complete. */
252
+ readonly next: string | undefined;
253
+ }
254
+
255
+ /**
256
+ * Validates the untrusted `{ data, cursor }` envelope. Unknown *items* are
257
+ * returned as-is and are snapshotted individually afterwards; the envelope's
258
+ * `next` is only ever used as an opaque string or as a stop signal.
259
+ */
260
+ function parsePageEnvelope(value: unknown, code: SessionCollectionErrorCode, label: string): ParsedPage {
261
+ if (!isJsonObject(value)) {
262
+ throw new SessionCollectionError(code, `${label} was not an object`);
263
+ }
264
+ const data = value["data"];
265
+ if (!Array.isArray(data)) {
266
+ throw new SessionCollectionError(code, `${label} did not contain a data array`);
267
+ }
268
+
269
+ let next: string | undefined;
270
+ const cursor = value["cursor"];
271
+ if (cursor !== undefined && cursor !== null) {
272
+ if (!isJsonObject(cursor)) {
273
+ throw new SessionCollectionError(code, `${label} cursor was not an object`);
274
+ }
275
+ const rawNext = cursor["next"];
276
+ if (rawNext !== undefined && rawNext !== null) {
277
+ if (typeof rawNext !== "string" || rawNext.length === 0) {
278
+ throw new SessionCollectionError(code, `${label} cursor.next was not a non-empty string or null`);
279
+ }
280
+ next = rawNext;
281
+ }
282
+ }
283
+
284
+ if (next !== undefined && data.length === 0) {
285
+ throw new SessionCollectionError(
286
+ code,
287
+ `${label} returned no items with a next cursor; refusing to guess at an unbounded page sequence`,
288
+ );
289
+ }
290
+
291
+ return { items: data, next };
292
+ }
293
+
294
+ /** Copies one API record into a detached JSON-only object, rejecting anything JSON would omit or coerce. */
295
+ function snapshotJsonRecord(value: unknown, code: SessionCollectionErrorCode, label: string): JsonObject {
296
+ if (!isJsonObject(value)) {
297
+ throw new SessionCollectionError(code, `${label} was not an object`);
298
+ }
299
+ let snapshot: JsonValue;
300
+ try {
301
+ snapshot = snapshotJsonValue(value, label);
302
+ } catch (cause) {
303
+ throw new SessionCollectionError(code, `${label} is not JSON-stable: ${errorText(cause)}`, { cause });
304
+ }
305
+ if (!isJsonObject(snapshot)) {
306
+ throw new SessionCollectionError(code, `${label} did not snapshot to an object`);
307
+ }
308
+ return snapshot as JsonObject;
309
+ }
310
+
311
+ /** Awaits one port call and converts non-collection failures into a coded error while preserving the cause. */
312
+ async function callPort(
313
+ call: () => Promise<unknown>,
314
+ code: SessionCollectionErrorCode,
315
+ label: string,
316
+ ): Promise<unknown> {
317
+ try {
318
+ return await call();
319
+ } catch (cause) {
320
+ if (cause instanceof SessionCollectionError) throw cause;
321
+ throw new SessionCollectionError(code, `${label}: ${errorText(cause)}`, { cause });
322
+ }
323
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Fail-closed failure codes for session collection.
3
+ *
4
+ * A thrown `SessionCollectionError` always means the collection stopped without
5
+ * producing a payload or serialized bytes. Callers must never catch it and
6
+ * continue with a partially collected result (P-05: complete history or no
7
+ * artifact; no silent filtering or truncation).
8
+ */
9
+ export type SessionCollectionErrorCode =
10
+ | "invalid-request"
11
+ | "invalid-policy"
12
+ | "session-list-failed"
13
+ | "message-list-failed"
14
+ | "malformed-session-page"
15
+ | "malformed-message-page"
16
+ | "malformed-session-record"
17
+ | "malformed-message-record"
18
+ | "session-project-mismatch"
19
+ | "duplicate-session-id"
20
+ | "duplicate-message-id"
21
+ | "repeated-session-cursor"
22
+ | "repeated-message-cursor"
23
+ | "session-limit-exceeded"
24
+ | "message-limit-exceeded"
25
+ | "total-message-limit-exceeded"
26
+ | "unsettled-message";
27
+
28
+ /** Collection failure with a stable machine-readable code; `cause` preserves the underlying API/validation error. */
29
+ export class SessionCollectionError extends Error {
30
+ readonly code: SessionCollectionErrorCode;
31
+
32
+ constructor(code: SessionCollectionErrorCode, message: string, options?: ErrorOptions) {
33
+ super(message, options);
34
+ this.name = "SessionCollectionError";
35
+ this.code = code;
36
+ }
37
+ }
@@ -0,0 +1,11 @@
1
+ /** Internal runtime guards shared by the session collection modules. */
2
+
3
+ /** True for JSON-shaped object values (non-null, not an array). */
4
+ export function isJsonObject(value: unknown): value is { readonly [key: string]: unknown } {
5
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6
+ }
7
+
8
+ /** Best-effort message text for wrapping a caught `unknown` value. */
9
+ export function errorText(value: unknown): string {
10
+ return value instanceof Error ? value.message : String(value);
11
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * SessionPort V1 core session lane (Phase 2).
3
+ *
4
+ * Bounded session enumeration, detached JSON snapshotting, and payload
5
+ * serialization against the approved shared contracts. Session records are
6
+ * bound to the requested scope fail-closed: each session `projectID` must
7
+ * equal the requested project or the whole collection rejects. Message
8
+ * association is endpoint-scoped through `listMessages(sessionID, ...)`: the
9
+ * v2.0.22 route scopes messages to its `:sessionID` path parameter, message
10
+ * records carry no `sessionID` of their own, and no per-record association
11
+ * check or normalization is invented. No TUI/OpenCode imports; the host API
12
+ * is reached only through the injected `SessionReadPort`.
13
+ *
14
+ * Consistency caveat (O-21 / OQ-23): upstream list APIs document no atomic
15
+ * snapshot. A successful collection is a best-effort page sequence, not a
16
+ * consistent point-in-time capture, and this lane invents no retry policy.
17
+ * Canonical duplicate comparison (O-04) and ID/reference conflict remapping
18
+ * (O-05) are intentionally not implemented here.
19
+ */
20
+ export { collectCheckpointPayload } from "./collector.ts";
21
+ export type { CollectCheckpointPayloadRequest } from "./collector.ts";
22
+ export { SessionCollectionError } from "./errors.ts";
23
+ export type { SessionCollectionErrorCode } from "./errors.ts";
24
+ export { MESSAGE_PAGE_SIZE_CAP, validateSessionCollectionPolicy } from "./policy.ts";
25
+ export type { SessionCollectionPolicy } from "./policy.ts";
26
+ export type { MessageListPageRequest, SessionListPageRequest, SessionReadPort } from "./port.ts";
27
+ export { serializeCheckpointPayload } from "./serialize.ts";
28
+ export { findUnsettledMessageReason } from "./unsettled.ts";
29
+ export type { UnsettledMessageReason } from "./unsettled.ts";
@@ -0,0 +1,80 @@
1
+ import { SessionCollectionError } from "./errors.ts";
2
+ import { isJsonObject } from "./guards.ts";
3
+
4
+ /**
5
+ * Explicit, required collection bounds. Every field has no default and must be
6
+ * supplied by the caller: this lane deliberately ships no production caps or
7
+ * hidden fallbacks (O-03 / OQ-03 remain OPEN and must be benchmarked before
8
+ * freeze). Tests may pass synthetic small values.
9
+ *
10
+ * Exceeding any bound fails the whole collection; the collector never emits a
11
+ * silently truncated or partial payload.
12
+ */
13
+ export interface SessionCollectionPolicy {
14
+ /** Page size for `GET /api/session` requests (v2.0.22 declares a default of 50, not a cap). */
15
+ readonly sessionPageSize: number;
16
+ /** Page size for message list requests; must not exceed the declared v2.0.22 cap of 200. */
17
+ readonly messagePageSize: number;
18
+ /** Hard bound on the number of sessions collected across all pages. */
19
+ readonly maxSessions: number;
20
+ /** Hard bound on messages collected for a single session. */
21
+ readonly maxMessagesPerSession: number;
22
+ /**
23
+ * Hard bound on messages collected across all sessions and pages; checked
24
+ * against the number already collected plus a whole page before that page
25
+ * is accumulated, so a page that would push the collection over the bound
26
+ * rejects it instead of being truncated. Zero is allowed and forbids
27
+ * collecting any message.
28
+ */
29
+ readonly maxTotalMessages: number;
30
+ }
31
+
32
+ /** Declared v2.0.22 maximum page size for `GET /api/session/:sessionID/message` (E14). */
33
+ export const MESSAGE_PAGE_SIZE_CAP = 200;
34
+
35
+ /**
36
+ * Validates an explicit policy at the collection boundary. Invalid or missing
37
+ * limits fail closed instead of silently substituting a default.
38
+ */
39
+ export function validateSessionCollectionPolicy(policy: SessionCollectionPolicy): void {
40
+ if (!isJsonObject(policy)) {
41
+ throw new SessionCollectionError(
42
+ "invalid-policy",
43
+ "SessionCollectionPolicy is required and must be an explicit object with sessionPageSize, messagePageSize, maxSessions, maxMessagesPerSession, and maxTotalMessages; no defaults are applied",
44
+ );
45
+ }
46
+ requirePositiveSafeInteger(policy.sessionPageSize, "sessionPageSize");
47
+ requirePositiveSafeInteger(policy.messagePageSize, "messagePageSize");
48
+ if (typeof policy.messagePageSize === "number" && policy.messagePageSize > MESSAGE_PAGE_SIZE_CAP) {
49
+ throw new SessionCollectionError(
50
+ "invalid-policy",
51
+ `SessionCollectionPolicy.messagePageSize must not exceed the declared message page cap of ${MESSAGE_PAGE_SIZE_CAP} (got ${policy.messagePageSize})`,
52
+ );
53
+ }
54
+ requirePositiveSafeInteger(policy.maxSessions, "maxSessions");
55
+ requirePositiveSafeInteger(policy.maxMessagesPerSession, "maxMessagesPerSession");
56
+ requireNonNegativeSafeInteger(policy.maxTotalMessages, "maxTotalMessages");
57
+ }
58
+
59
+ function requirePositiveSafeInteger(value: unknown, field: keyof SessionCollectionPolicy): void {
60
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) {
61
+ throw new SessionCollectionError(
62
+ "invalid-policy",
63
+ `SessionCollectionPolicy.${field} must be an explicit positive safe integer (got ${String(value)})`,
64
+ );
65
+ }
66
+ }
67
+
68
+ /**
69
+ * `maxTotalMessages` is an aggregate cap where zero is a meaningful,
70
+ * fail-closed setting ("collect no messages"), so it accepts zero while the
71
+ * other bounds require at least one.
72
+ */
73
+ function requireNonNegativeSafeInteger(value: unknown, field: keyof SessionCollectionPolicy): void {
74
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
75
+ throw new SessionCollectionError(
76
+ "invalid-policy",
77
+ `SessionCollectionPolicy.${field} must be an explicit non-negative safe integer (got ${String(value)})`,
78
+ );
79
+ }
80
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Dependency-injected read surface for session enumeration.
3
+ *
4
+ * This is the only host boundary the session core lane needs (no TUI/OpenCode
5
+ * imports here). Implementations live outside this module and map these calls
6
+ * onto the host client; tests inject synthetic ports.
7
+ *
8
+ * Expected upstream behavior (OpenCode v2.0.22 declarations/source research,
9
+ * E13/E14; declarations vs. runtime behavior still partially unvalidated —
10
+ * OQ-10):
11
+ * - `listSessions` maps to `GET /api/session`: declared filters include
12
+ * `project`/`directory`; default page size 50; opaque cursor pagination with
13
+ * no total/hasMore. Responses are `{ data: [...], cursor: { next?: string | null } }`.
14
+ * - `listMessages` maps to `GET /api/session/:sessionID/message`: declared
15
+ * default page size 50, cap 200, default order `desc`; `order` cannot be
16
+ * combined with `cursor`; secret-type filtering is possible and must be
17
+ * omitted to preserve every message variant. The `:sessionID` path
18
+ * parameter is the ownership boundary: the endpoint scopes returned records
19
+ * to that session, and v2.0.22 message records carry no per-message
20
+ * `sessionID` (their common Base is `id`/metadata/`time`), so a record
21
+ * cannot independently assert session association.
22
+ *
23
+ * Response values are treated as untrusted `unknown` and are validated into
24
+ * detached JSON-only snapshots by the collector; a port must not pre-filter,
25
+ * pre-truncate, or coerce API data.
26
+ *
27
+ * Consistency caveat (O-21 / OQ-23): the upstream list APIs document no atomic
28
+ * snapshot or snapshot-consistency guarantee. A successful collection is a
29
+ * sequence of page reads, not a consistent point-in-time capture; no retry or
30
+ * re-read policy is defined here. Do not describe this utility as a complete
31
+ * checkpoint transaction — compression/encryption/writing remain separate.
32
+ */
33
+ export interface SessionReadPort {
34
+ /**
35
+ * Requests one page of the session list scoped to the explicit project.
36
+ * Expected response: `{ data: unknown[]; cursor?: { next?: string | null } | null }`.
37
+ */
38
+ listSessions(request: SessionListPageRequest): Promise<unknown>;
39
+
40
+ /**
41
+ * Requests one page of messages for exactly one session, addressed by the
42
+ * `sessionID` field of `MessageListPageRequest`. That request target is the
43
+ * ownership boundary: the upstream route scopes returned records to the
44
+ * path session and v2.0.22 message records carry no `sessionID`, so
45
+ * per-record association cannot be independently asserted and must not be
46
+ * inferred, required, or normalized. The first call must use `order: "asc"`;
47
+ * later calls pass the opaque `cursor` and no `order`. No `type` filter is
48
+ * ever sent.
49
+ * Expected response: `{ data: unknown[]; cursor?: { next?: string | null } | null }`.
50
+ */
51
+ listMessages(request: MessageListPageRequest): Promise<unknown>;
52
+ }
53
+
54
+ /** One session list page request. The project scope is always explicit; the collector never infers it. */
55
+ export interface SessionListPageRequest {
56
+ /** Explicit project scope; mapped to the list route's `project` filter. */
57
+ readonly project: string;
58
+ /** Explicit page size from `SessionCollectionPolicy`; the API default is never relied on. */
59
+ readonly limit: number;
60
+ /** Opaque `cursor.next` from the previous page; absent on the first request. */
61
+ readonly cursor?: string;
62
+ }
63
+
64
+ /**
65
+ * One message list page request.
66
+ *
67
+ * `sessionID` is the endpoint-scoped ownership boundary (the route path
68
+ * parameter) and is always the session currently being collected; returned
69
+ * records are not expected to repeat it (v2.0.22 message records have no
70
+ * `sessionID` field). The union mirrors the declared API constraint that
71
+ * `order` and `cursor` cannot be combined: the first page is `{ order: "asc" }`
72
+ * with no cursor, and every subsequent page carries only the opaque cursor. No
73
+ * `type` filter is ever sent so all message variants and tool content are
74
+ * preserved.
75
+ */
76
+ export type MessageListPageRequest =
77
+ | {
78
+ readonly sessionID: string;
79
+ readonly limit: number;
80
+ /** First page only: ascending order. */
81
+ readonly order: "asc";
82
+ }
83
+ | {
84
+ readonly sessionID: string;
85
+ readonly limit: number;
86
+ /** Subsequent pages only: opaque cursor; no `order`, no `type` filter. */
87
+ readonly cursor: string;
88
+ };
@@ -0,0 +1,15 @@
1
+ import { encodeJson, type CheckpointPayload } from "../contracts/index.ts";
2
+
3
+ /**
4
+ * Serializes a complete, successfully collected `CheckpointPayload` to detached
5
+ * JSON bytes via the shared `encodeJson` contract.
6
+ *
7
+ * This is only the serialization step of a checkpoint; compression, encryption,
8
+ * and artifact writing are separate. The collector always either returns a
9
+ * complete payload or throws, so there is no partial payload to serialize.
10
+ * `encodeJson` additionally rejects non-JSON-stable values instead of silently
11
+ * omitting or coercing them.
12
+ */
13
+ export function serializeCheckpointPayload(payload: CheckpointPayload): Uint8Array {
14
+ return encodeJson(payload);
15
+ }
@@ -0,0 +1,35 @@
1
+ import type { JsonObject } from "../contracts/index.ts";
2
+ import { isJsonObject } from "./guards.ts";
3
+
4
+ /**
5
+ * Reasons a message is "unsettled" under the official v2.0.22 transfer/import
6
+ * filter (E15). Such messages would be silently dropped by import/export;
7
+ * because completeness is DECIDED (P-05) the checkpoint must fail closed
8
+ * instead of filtering them.
9
+ */
10
+ export type UnsettledMessageReason =
11
+ | "assistant-without-completion"
12
+ | "shell-running"
13
+ | "compaction-running";
14
+
15
+ /**
16
+ * Detects messages that official transfer/import would drop:
17
+ * - assistant message without a numeric `time.completed`;
18
+ * - shell message with `status: "running"`;
19
+ * - compaction message with `status: "running"`.
20
+ *
21
+ * Unknown message types, unknown statuses, and unknown fields are preserved,
22
+ * not filtered or coerced. This mirrors the researched v2.0.22 rules only; it
23
+ * is not a general schema validator and it makes no claim beyond E15.
24
+ */
25
+ export function findUnsettledMessageReason(message: JsonObject): UnsettledMessageReason | undefined {
26
+ const type = message["type"];
27
+ if (type === "assistant") {
28
+ const time = message["time"];
29
+ const completed = isJsonObject(time) ? time["completed"] : undefined;
30
+ if (typeof completed !== "number") return "assistant-without-completion";
31
+ }
32
+ if (type === "shell" && message["status"] === "running") return "shell-running";
33
+ if (type === "compaction" && message["status"] === "running") return "compaction-running";
34
+ return undefined;
35
+ }
@@ -0,0 +1,28 @@
1
+ /** V1 OMO boundary: no stable public session-transfer API exists, so transfer no OMO data. */
2
+ export type OmoNoopResult = {
3
+ readonly status: "no-op";
4
+ readonly reason: "no-public-session-transfer-api";
5
+ };
6
+
7
+ export interface OmoAdapter {
8
+ capture(): Promise<OmoNoopResult>;
9
+ restore(): Promise<OmoNoopResult>;
10
+ }
11
+
12
+ const RESULT: OmoNoopResult = Object.freeze({
13
+ status: "no-op",
14
+ reason: "no-public-session-transfer-api",
15
+ });
16
+
17
+ /**
18
+ * Deliberately does not inspect OMO configuration, package inventory, job-board
19
+ * state, credentials, agents, tools, or host-managed files. Native OpenCode
20
+ * sessions are the complete V1 payload; a future adapter requires an explicit,
21
+ * stable, versioned public transfer API.
22
+ */
23
+ export function createOmoAdapter(): OmoAdapter {
24
+ return Object.freeze({
25
+ capture: async () => RESULT,
26
+ restore: async () => RESULT,
27
+ });
28
+ }