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,251 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin/tui";
|
|
2
|
+
import { useKeyboard } from "@opentui/solid";
|
|
3
|
+
import { createSignal } from "solid-js";
|
|
4
|
+
import {
|
|
5
|
+
RestoreReportNavigator,
|
|
6
|
+
type RestoreReportNavigationSnapshot,
|
|
7
|
+
type RestoreReportPresentationModel,
|
|
8
|
+
type RestoreReportSession,
|
|
9
|
+
type RestoreReportStatus,
|
|
10
|
+
} from "./restore-report-flow.ts";
|
|
11
|
+
|
|
12
|
+
export type {
|
|
13
|
+
RestoreReportClassification,
|
|
14
|
+
RestoreReportPresentationModel,
|
|
15
|
+
RestoreReportSession,
|
|
16
|
+
RestoreReportStatus,
|
|
17
|
+
RestoreReportSummary,
|
|
18
|
+
} from "./restore-report-flow.ts";
|
|
19
|
+
|
|
20
|
+
const palette = {
|
|
21
|
+
background: "#111922",
|
|
22
|
+
field: "#202d3b",
|
|
23
|
+
edge: "#344557",
|
|
24
|
+
accent: "#53d6c3",
|
|
25
|
+
text: "#edf3f7",
|
|
26
|
+
muted: "#94a4b2",
|
|
27
|
+
gold: "#e5be76",
|
|
28
|
+
coral: "#ee907f",
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
type RestoreReportDialogContext = Pick<Plugin.Context, "ui">;
|
|
32
|
+
|
|
33
|
+
interface RestoreReportDialogProps {
|
|
34
|
+
readonly model: RestoreReportPresentationModel;
|
|
35
|
+
readonly navigator: RestoreReportNavigator;
|
|
36
|
+
readonly close: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ConsumableKeyEvent {
|
|
40
|
+
preventDefault(): void;
|
|
41
|
+
stopPropagation(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function consumeKey(event: ConsumableKeyEvent): void {
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
event.stopPropagation();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function statusColor(status: RestoreReportStatus): string {
|
|
50
|
+
switch (status) {
|
|
51
|
+
case "imported":
|
|
52
|
+
return palette.accent;
|
|
53
|
+
case "failed":
|
|
54
|
+
return palette.coral;
|
|
55
|
+
case "blocked":
|
|
56
|
+
return palette.gold;
|
|
57
|
+
case "skipped":
|
|
58
|
+
case "not-attempted":
|
|
59
|
+
return palette.muted;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isPartial(summary: RestoreReportPresentationModel["summary"]): boolean {
|
|
64
|
+
return summary.failed > 0 || summary.blocked > 0 || summary.notAttempted > 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function SummaryLine(props: {
|
|
68
|
+
readonly total: number;
|
|
69
|
+
readonly imported: number;
|
|
70
|
+
readonly skipped: number;
|
|
71
|
+
readonly failed: number;
|
|
72
|
+
readonly blocked: number;
|
|
73
|
+
readonly notAttempted: number;
|
|
74
|
+
}) {
|
|
75
|
+
return (
|
|
76
|
+
<box flexDirection="column" gap={0}>
|
|
77
|
+
<text wrapMode="char" fg={palette.text}>
|
|
78
|
+
{props.total.toLocaleString()} total · {props.imported.toLocaleString()} imported · {props.skipped.toLocaleString()} skipped
|
|
79
|
+
</text>
|
|
80
|
+
<text wrapMode="char" fg={palette.text}>
|
|
81
|
+
{props.failed.toLocaleString()} failed · {props.blocked.toLocaleString()} blocked · {props.notAttempted.toLocaleString()} not attempted
|
|
82
|
+
</text>
|
|
83
|
+
</box>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function SessionOutcome(props: { readonly session: RestoreReportSession }) {
|
|
88
|
+
return (
|
|
89
|
+
<box
|
|
90
|
+
flexDirection="column"
|
|
91
|
+
gap={0}
|
|
92
|
+
border
|
|
93
|
+
borderStyle="rounded"
|
|
94
|
+
borderColor={palette.edge}
|
|
95
|
+
backgroundColor={palette.field}
|
|
96
|
+
paddingX={1}
|
|
97
|
+
paddingY={0}
|
|
98
|
+
>
|
|
99
|
+
<text fg={palette.muted}>Session ID</text>
|
|
100
|
+
<text wrapMode="char" fg={palette.text}>{props.session.sessionID}</text>
|
|
101
|
+
|
|
102
|
+
<box flexDirection="row" gap={1}>
|
|
103
|
+
<text fg={palette.muted}>Classification</text>
|
|
104
|
+
<text wrapMode="char" fg={palette.text}>{props.session.classification}</text>
|
|
105
|
+
</box>
|
|
106
|
+
<box flexDirection="row" gap={1}>
|
|
107
|
+
<text fg={palette.muted}>Outcome</text>
|
|
108
|
+
<text wrapMode="char" fg={statusColor(props.session.status)}>{props.session.status}</text>
|
|
109
|
+
</box>
|
|
110
|
+
|
|
111
|
+
{props.session.skipReason !== undefined && (
|
|
112
|
+
<box flexDirection="column" gap={0}>
|
|
113
|
+
<text fg={palette.muted}>Skip reason</text>
|
|
114
|
+
{/* Keep the supplied sanitized reason verbatim; do not ellipsize it. */}
|
|
115
|
+
<text wrapMode="char" fg={palette.text}>{props.session.skipReason}</text>
|
|
116
|
+
</box>
|
|
117
|
+
)}
|
|
118
|
+
</box>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function RestoreReportDialog(props: RestoreReportDialogProps) {
|
|
123
|
+
const [navigation, setNavigation] = createSignal(props.navigator.snapshot());
|
|
124
|
+
const move = (next: RestoreReportNavigationSnapshot) => setNavigation(next);
|
|
125
|
+
|
|
126
|
+
useKeyboard((event) => {
|
|
127
|
+
const key = event.name.toLowerCase();
|
|
128
|
+
const sequence = event.sequence.toLowerCase();
|
|
129
|
+
|
|
130
|
+
if (key === "escape" || key === "esc") {
|
|
131
|
+
consumeKey(event);
|
|
132
|
+
props.close();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (["left", "arrowleft", "up", "arrowup", "pageup"].includes(key) || sequence === "k") {
|
|
137
|
+
consumeKey(event);
|
|
138
|
+
move(props.navigator.previous());
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (["right", "arrowright", "down", "arrowdown", "pagedown"].includes(key) || sequence === "j") {
|
|
142
|
+
consumeKey(event);
|
|
143
|
+
move(props.navigator.next());
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (key === "home") {
|
|
147
|
+
consumeKey(event);
|
|
148
|
+
move(props.navigator.first());
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (key === "end") {
|
|
152
|
+
consumeKey(event);
|
|
153
|
+
move(props.navigator.last());
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const summary = props.model.summary;
|
|
158
|
+
const partial = isPartial(summary);
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<box
|
|
162
|
+
flexDirection="column"
|
|
163
|
+
width={74}
|
|
164
|
+
gap={1}
|
|
165
|
+
border
|
|
166
|
+
borderStyle="rounded"
|
|
167
|
+
borderColor={palette.edge}
|
|
168
|
+
backgroundColor={palette.background}
|
|
169
|
+
paddingX={2}
|
|
170
|
+
paddingY={1}
|
|
171
|
+
>
|
|
172
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
173
|
+
<text fg={palette.accent}>SESSIONPORT</text>
|
|
174
|
+
<text fg={palette.muted}>RESTORE REPORT</text>
|
|
175
|
+
</box>
|
|
176
|
+
|
|
177
|
+
<box flexDirection="column" gap={0}>
|
|
178
|
+
<text fg={palette.text}>Restore finished</text>
|
|
179
|
+
<SummaryLine {...summary} />
|
|
180
|
+
</box>
|
|
181
|
+
|
|
182
|
+
{partial && (
|
|
183
|
+
<box
|
|
184
|
+
flexDirection="row"
|
|
185
|
+
border
|
|
186
|
+
borderStyle="rounded"
|
|
187
|
+
borderColor={palette.gold}
|
|
188
|
+
paddingX={1}
|
|
189
|
+
>
|
|
190
|
+
<text wrapMode="char" fg={palette.gold}>
|
|
191
|
+
Partial progress: there is no atomic rollback; imported sessions remain.
|
|
192
|
+
</text>
|
|
193
|
+
</box>
|
|
194
|
+
)}
|
|
195
|
+
|
|
196
|
+
<box flexDirection="column" gap={0}>
|
|
197
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
198
|
+
<text fg={palette.accent}>SESSION OUTCOME</text>
|
|
199
|
+
<text fg={palette.muted}>
|
|
200
|
+
{navigation().count === 0
|
|
201
|
+
? "NO RESULTS"
|
|
202
|
+
: `${(navigation().index + 1).toLocaleString()} OF ${navigation().count.toLocaleString()}`}
|
|
203
|
+
</text>
|
|
204
|
+
</box>
|
|
205
|
+
{navigation().session ? (
|
|
206
|
+
<SessionOutcome session={navigation().session!} />
|
|
207
|
+
) : (
|
|
208
|
+
<box
|
|
209
|
+
flexDirection="column"
|
|
210
|
+
border
|
|
211
|
+
borderStyle="rounded"
|
|
212
|
+
borderColor={palette.edge}
|
|
213
|
+
backgroundColor={palette.field}
|
|
214
|
+
paddingX={1}
|
|
215
|
+
>
|
|
216
|
+
<text wrapMode="char" fg={palette.muted}>No per-session outcomes were returned.</text>
|
|
217
|
+
</box>
|
|
218
|
+
)}
|
|
219
|
+
</box>
|
|
220
|
+
|
|
221
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
222
|
+
<text wrapMode="char" fg={palette.muted}>
|
|
223
|
+
←/↑ previous · →/↓ next · Home/End jump
|
|
224
|
+
</text>
|
|
225
|
+
<text fg={palette.accent}>Esc CLOSE</text>
|
|
226
|
+
</box>
|
|
227
|
+
</box>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Show a keyboard-navigable restore report using only a sanitized presentation
|
|
233
|
+
* model. Each page contains one complete session outcome; long IDs and reason
|
|
234
|
+
* text wrap rather than being clipped. Escape closes the report.
|
|
235
|
+
*/
|
|
236
|
+
export function showRestoreReportDialog(
|
|
237
|
+
context: RestoreReportDialogContext,
|
|
238
|
+
model: RestoreReportPresentationModel,
|
|
239
|
+
): void {
|
|
240
|
+
const navigator = new RestoreReportNavigator(model.sessions);
|
|
241
|
+
context.ui.dialog.set({ size: "large", centered: true });
|
|
242
|
+
context.ui.dialog.show(
|
|
243
|
+
() => (
|
|
244
|
+
<RestoreReportDialog
|
|
245
|
+
model={model}
|
|
246
|
+
navigator={navigator}
|
|
247
|
+
close={() => context.ui.dialog.clear()}
|
|
248
|
+
/>
|
|
249
|
+
),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small, host-independent cursor for browsing restore outcomes. The report
|
|
3
|
+
* surface shows one session at a time so IDs and reason text are never
|
|
4
|
+
* shortened to fit a notification or a crowded list.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type RestoreReportClassification = "skipped" | "imported" | "renamed";
|
|
8
|
+
|
|
9
|
+
export type RestoreReportStatus =
|
|
10
|
+
| "imported"
|
|
11
|
+
| "skipped"
|
|
12
|
+
| "failed"
|
|
13
|
+
| "blocked"
|
|
14
|
+
| "not-attempted";
|
|
15
|
+
|
|
16
|
+
/** Presentation-safe per-session facts. Never put a host exception here. */
|
|
17
|
+
export interface RestoreReportSession {
|
|
18
|
+
readonly sessionID: string;
|
|
19
|
+
readonly classification: RestoreReportClassification;
|
|
20
|
+
readonly status: RestoreReportStatus;
|
|
21
|
+
/** A sanitized planner skip reason, shown verbatim when present. */
|
|
22
|
+
readonly skipReason?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RestoreReportSummary {
|
|
26
|
+
readonly total: number;
|
|
27
|
+
readonly imported: number;
|
|
28
|
+
readonly skipped: number;
|
|
29
|
+
readonly failed: number;
|
|
30
|
+
readonly blocked: number;
|
|
31
|
+
readonly notAttempted: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Stable, already-sanitized input for the restore report dialog. It contains
|
|
36
|
+
* only aggregate counts and display-safe per-session fields; no exception
|
|
37
|
+
* object or host error text is accepted or rendered.
|
|
38
|
+
*/
|
|
39
|
+
export interface RestoreReportPresentationModel {
|
|
40
|
+
readonly summary: RestoreReportSummary;
|
|
41
|
+
readonly sessions: readonly RestoreReportSession[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RestoreReportNavigationSnapshot {
|
|
45
|
+
/** Zero-based position; remains 0 for an empty report. */
|
|
46
|
+
readonly index: number;
|
|
47
|
+
readonly count: number;
|
|
48
|
+
readonly pageCount: number;
|
|
49
|
+
readonly session: RestoreReportSession | undefined;
|
|
50
|
+
readonly canPrevious: boolean;
|
|
51
|
+
readonly canNext: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Pure cursor state used by the TUI and by state-only tests. */
|
|
55
|
+
export class RestoreReportNavigator {
|
|
56
|
+
readonly #sessions: readonly RestoreReportSession[];
|
|
57
|
+
#index = 0;
|
|
58
|
+
|
|
59
|
+
constructor(sessions: readonly RestoreReportSession[]) {
|
|
60
|
+
this.#sessions = sessions;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
snapshot(): RestoreReportNavigationSnapshot {
|
|
64
|
+
const count = this.#sessions.length;
|
|
65
|
+
return Object.freeze({
|
|
66
|
+
index: this.#index,
|
|
67
|
+
count,
|
|
68
|
+
pageCount: count,
|
|
69
|
+
session: this.#sessions[this.#index],
|
|
70
|
+
canPrevious: this.#index > 0,
|
|
71
|
+
canNext: this.#index + 1 < count,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
previous(): RestoreReportNavigationSnapshot {
|
|
76
|
+
if (this.#index > 0) this.#index -= 1;
|
|
77
|
+
return this.snapshot();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
next(): RestoreReportNavigationSnapshot {
|
|
81
|
+
if (this.#index + 1 < this.#sessions.length) this.#index += 1;
|
|
82
|
+
return this.snapshot();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
first(): RestoreReportNavigationSnapshot {
|
|
86
|
+
this.#index = 0;
|
|
87
|
+
return this.snapshot();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
last(): RestoreReportNavigationSnapshot {
|
|
91
|
+
this.#index = Math.max(0, this.#sessions.length - 1);
|
|
92
|
+
return this.snapshot();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin/tui";
|
|
2
|
+
|
|
3
|
+
export const SESSIONPORT_COMMAND_IDS = {
|
|
4
|
+
checkpoint: "sessionport.checkpoint",
|
|
5
|
+
restore: "sessionport.restore",
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
export interface SessionPortCommandHandlers {
|
|
9
|
+
readonly checkpoint: (rawInput: string | undefined) => Promise<void>;
|
|
10
|
+
readonly restore: (rawInput: string | undefined) => Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The host APIs this registration seam uses. */
|
|
14
|
+
export interface SessionPortCommandHost {
|
|
15
|
+
readonly ui: Pick<Plugin.Context["ui"], "slot">;
|
|
16
|
+
readonly keymap: Pick<Plugin.Context["keymap"], "layer">;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Register native slash commands from an app-slot component scope. The slot
|
|
21
|
+
* disposer is returned unchanged so the plugin can clean up its contribution.
|
|
22
|
+
* Command input is forwarded as received; parsing belongs to the injected
|
|
23
|
+
* handlers.
|
|
24
|
+
*/
|
|
25
|
+
export function installSessionPortCommands(
|
|
26
|
+
context: SessionPortCommandHost,
|
|
27
|
+
handlers: SessionPortCommandHandlers,
|
|
28
|
+
): () => void {
|
|
29
|
+
return context.ui.slot({
|
|
30
|
+
append: "app",
|
|
31
|
+
render() {
|
|
32
|
+
context.keymap.layer(() => ({
|
|
33
|
+
commands: [
|
|
34
|
+
{
|
|
35
|
+
id: SESSIONPORT_COMMAND_IDS.checkpoint,
|
|
36
|
+
title: "Checkpoint project sessions",
|
|
37
|
+
description:
|
|
38
|
+
"Create a password-encrypted checkpoint of the current project's sessions.",
|
|
39
|
+
group: "SessionPort",
|
|
40
|
+
bind: false,
|
|
41
|
+
palette: true,
|
|
42
|
+
slash: { name: "checkpoint", arguments: true },
|
|
43
|
+
run: async (rawInput) => {
|
|
44
|
+
await handlers.checkpoint(rawInput);
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: SESSIONPORT_COMMAND_IDS.restore,
|
|
49
|
+
title: "Restore project sessions",
|
|
50
|
+
description:
|
|
51
|
+
"Choose an existing project and restore a checkpoint without overwriting its local sessions.",
|
|
52
|
+
group: "SessionPort",
|
|
53
|
+
bind: false,
|
|
54
|
+
palette: true,
|
|
55
|
+
slash: { name: "restore", arguments: true },
|
|
56
|
+
run: async (rawInput) => {
|
|
57
|
+
await handlers.restore(rawInput);
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
}));
|
|
62
|
+
return null;
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|