@mirrowel/opencode-openshare 0.1.1 → 0.2.0-dev.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.
- package/ci/openshare.js +583 -160
- package/dist/contract.d.ts +156 -0
- package/dist/contract.js +263 -0
- package/dist/core.js +411 -55
- package/dist/dialogs.js +38 -0
- package/dist/sidebar.d.ts +3 -1
- package/dist/sidebar.js +3 -17
- package/dist/tui.js +31 -36
- package/dist/v2-server.d.ts +0 -27
- package/dist/v2-server.js +6 -121
- package/package.json +3 -1
- package/src/contract.ts +320 -0
- package/src/core.ts +403 -56
- package/src/dialogs.tsx +34 -0
- package/src/sidebar.tsx +4 -21
- package/src/v2-server.ts +7 -110
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
export declare const CONTRACT_VERSION = 2;
|
|
2
|
+
/** Message tombstone item. sessionID MUST ride along: viewers key
|
|
3
|
+
* messages as message/<sessionID>/<id> — an unstamped del misses the
|
|
4
|
+
* key and undone turns resurrect on the live page (live-caught
|
|
5
|
+
* 2026-10-21, refresh-correct because the worker keys differently). */
|
|
6
|
+
export declare function messageTombstone(sessionID: string, messageID: string): {
|
|
7
|
+
readonly type: "message";
|
|
8
|
+
readonly del: true;
|
|
9
|
+
readonly sessionID: string;
|
|
10
|
+
readonly data: {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
export type V2ToolState = {
|
|
15
|
+
status: "streaming";
|
|
16
|
+
input: string;
|
|
17
|
+
} | {
|
|
18
|
+
status: "running";
|
|
19
|
+
input: Record<string, unknown>;
|
|
20
|
+
metadata: Record<string, unknown>;
|
|
21
|
+
} | {
|
|
22
|
+
status: "completed";
|
|
23
|
+
input: Record<string, unknown>;
|
|
24
|
+
content: Array<{
|
|
25
|
+
type: "text";
|
|
26
|
+
text: string;
|
|
27
|
+
}>;
|
|
28
|
+
metadata?: Record<string, unknown>;
|
|
29
|
+
} | {
|
|
30
|
+
status: "error";
|
|
31
|
+
input: Record<string, unknown>;
|
|
32
|
+
error: {
|
|
33
|
+
type: string;
|
|
34
|
+
message: string;
|
|
35
|
+
};
|
|
36
|
+
content?: Array<{
|
|
37
|
+
type: "text";
|
|
38
|
+
text: string;
|
|
39
|
+
}>;
|
|
40
|
+
metadata?: Record<string, unknown>;
|
|
41
|
+
};
|
|
42
|
+
export type V2Content = {
|
|
43
|
+
type: "text";
|
|
44
|
+
text: string;
|
|
45
|
+
state?: string;
|
|
46
|
+
} | {
|
|
47
|
+
type: "reasoning";
|
|
48
|
+
text: string;
|
|
49
|
+
state?: string;
|
|
50
|
+
time?: {
|
|
51
|
+
created?: number;
|
|
52
|
+
completed?: number;
|
|
53
|
+
};
|
|
54
|
+
} | {
|
|
55
|
+
type: "tool";
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
executed?: boolean;
|
|
59
|
+
state: V2ToolState;
|
|
60
|
+
time: {
|
|
61
|
+
created?: number;
|
|
62
|
+
ran?: number;
|
|
63
|
+
completed?: number;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
export type V2FileAttachment = {
|
|
67
|
+
data: string;
|
|
68
|
+
mime: string;
|
|
69
|
+
source: {
|
|
70
|
+
type: "inline";
|
|
71
|
+
} | {
|
|
72
|
+
type: "uri";
|
|
73
|
+
uri: string;
|
|
74
|
+
};
|
|
75
|
+
name?: string;
|
|
76
|
+
};
|
|
77
|
+
export interface V2UserMessage {
|
|
78
|
+
id: string;
|
|
79
|
+
type: "user";
|
|
80
|
+
time: {
|
|
81
|
+
created: number;
|
|
82
|
+
};
|
|
83
|
+
text: string;
|
|
84
|
+
files?: V2FileAttachment[];
|
|
85
|
+
agents?: Array<{
|
|
86
|
+
name: string;
|
|
87
|
+
}>;
|
|
88
|
+
metadata?: Record<string, unknown>;
|
|
89
|
+
}
|
|
90
|
+
export interface V2AssistantMessage {
|
|
91
|
+
id: string;
|
|
92
|
+
type: "assistant";
|
|
93
|
+
agent: string;
|
|
94
|
+
model: {
|
|
95
|
+
id: string;
|
|
96
|
+
providerID: string;
|
|
97
|
+
variant?: string;
|
|
98
|
+
};
|
|
99
|
+
time: {
|
|
100
|
+
created: number;
|
|
101
|
+
streamed?: number;
|
|
102
|
+
completed?: number;
|
|
103
|
+
};
|
|
104
|
+
content: V2Content[];
|
|
105
|
+
cost?: number;
|
|
106
|
+
tokens?: Record<string, unknown>;
|
|
107
|
+
finish?: string;
|
|
108
|
+
error?: {
|
|
109
|
+
type: string;
|
|
110
|
+
message: string;
|
|
111
|
+
status?: number;
|
|
112
|
+
};
|
|
113
|
+
retry?: {
|
|
114
|
+
attempt: number;
|
|
115
|
+
at: number;
|
|
116
|
+
error?: {
|
|
117
|
+
type: string;
|
|
118
|
+
message: string;
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export interface V2CompactionMessage {
|
|
123
|
+
id: string;
|
|
124
|
+
type: "compaction";
|
|
125
|
+
status: "completed";
|
|
126
|
+
reason: "auto" | "manual";
|
|
127
|
+
summary: string;
|
|
128
|
+
recent: string;
|
|
129
|
+
time: {
|
|
130
|
+
created: number;
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
export type V2Message = V2UserMessage | V2AssistantMessage | V2CompactionMessage | Record<string, unknown>;
|
|
134
|
+
/** True when the object is already a v2 session message (v2 hosts pass
|
|
135
|
+
* messages through verbatim; v1 infos carry `role`). */
|
|
136
|
+
export declare function isV2NativeMessage(x: unknown): boolean;
|
|
137
|
+
/** v1 message + its parts → v2 message. Returns:
|
|
138
|
+
* - { message } — emit as a message item
|
|
139
|
+
* - { refill, summary } — v1 compaction summary assistant: do not emit;
|
|
140
|
+
* rewrite the parent compaction message instead
|
|
141
|
+
* - undefined — nothing to emit (should not happen in practice) */
|
|
142
|
+
export declare function normalizeV1Message(info: Record<string, unknown>, parts: Array<Record<string, unknown>>): {
|
|
143
|
+
message: V2Message;
|
|
144
|
+
} | {
|
|
145
|
+
refill: string;
|
|
146
|
+
summary: string;
|
|
147
|
+
} | undefined;
|
|
148
|
+
/** v1 session diff (FileDiff.Info[]) → v2 FileDiffInfo[]: the v2 shape
|
|
149
|
+
* REQUIRES numeric additions/deletions (renderers read them unguarded) —
|
|
150
|
+
* compute from the patch when the host didn't provide counts. */
|
|
151
|
+
export declare function normalizeV1Diff(diff: unknown): unknown;
|
|
152
|
+
/** v1 hosts paginate messages with an opaque cursor: base64url of
|
|
153
|
+
* {id, time} where time = the page tail's time_created (message-v2.ts
|
|
154
|
+
* cursor codec). A bare message id is rejected with 400 — that silently
|
|
155
|
+
* truncated every v1 backfill to one page (live-caught). */
|
|
156
|
+
export declare function cursorFor(id: string, time: number): string;
|
package/dist/contract.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// OpenShare transcript contract v2.
|
|
2
|
+
//
|
|
3
|
+
// A share is a set of keyed items (session / message / session_diff /
|
|
4
|
+
// model / prefs) with tombstone deletes. The CONTENT model is the opencode
|
|
5
|
+
// v2 session-message shapes, promoted into OUR envelope: other clients
|
|
6
|
+
// (pi, anything) can emit these shapes without running opencode, unknown
|
|
7
|
+
// message types degrade as notices in the viewer, and the share carries
|
|
8
|
+
// `v: 2` so consumers can dispatch on it. v1 hosts normalize at ingestion
|
|
9
|
+
// (below); v2 hosts pass messages through verbatim.
|
|
10
|
+
//
|
|
11
|
+
// Item set (envelope v2):
|
|
12
|
+
// session data = session info {id, title?, time, parentID?, ...}
|
|
13
|
+
// message data = v2 session message (see V2Message) — content is
|
|
14
|
+
// embedded; there are no separate part items
|
|
15
|
+
// session_diff data = FileDiffInfo[] (v2 review shapes)
|
|
16
|
+
// model data = [{id, providerID, name?}]
|
|
17
|
+
// prefs data = viewer preferences
|
|
18
|
+
// Tombstones: {type, del: true, data: {id}} — a message tombstone removes
|
|
19
|
+
// the whole message (parts are embedded; no cascade needed).
|
|
20
|
+
export const CONTRACT_VERSION = 2;
|
|
21
|
+
/** Message tombstone item. sessionID MUST ride along: viewers key
|
|
22
|
+
* messages as message/<sessionID>/<id> — an unstamped del misses the
|
|
23
|
+
* key and undone turns resurrect on the live page (live-caught
|
|
24
|
+
* 2026-10-21, refresh-correct because the worker keys differently). */
|
|
25
|
+
export function messageTombstone(sessionID, messageID) {
|
|
26
|
+
return { type: "message", del: true, sessionID, data: { id: messageID } };
|
|
27
|
+
}
|
|
28
|
+
// --- v1 → v2 normalization (the ingestion edge for v1 hosts) ---------------
|
|
29
|
+
/** True when the object is already a v2 session message (v2 hosts pass
|
|
30
|
+
* messages through verbatim; v1 infos carry `role`). */
|
|
31
|
+
export function isV2NativeMessage(x) {
|
|
32
|
+
if (!x || typeof x !== "object")
|
|
33
|
+
return false;
|
|
34
|
+
const r = x;
|
|
35
|
+
return typeof r.type === "string" && r.type !== "" && r.role === undefined && typeof r.id === "string";
|
|
36
|
+
}
|
|
37
|
+
const TOOL_NAME_MAP = {
|
|
38
|
+
bash: "shell",
|
|
39
|
+
task: "subagent",
|
|
40
|
+
};
|
|
41
|
+
// v1 read/edit/write carry `filePath`; v2 renderers read `path`
|
|
42
|
+
const INPUT_KEY_MAP = {
|
|
43
|
+
filePath: "path",
|
|
44
|
+
// the v2 kit's subagent row reads input.agent for the label (the real
|
|
45
|
+
// agent name, e.g. "explore"); v1's task tool calls it subagent_type
|
|
46
|
+
subagent_type: "agent",
|
|
47
|
+
};
|
|
48
|
+
function normalizeToolInput(input, title) {
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const [k, v] of Object.entries(input ?? {}))
|
|
51
|
+
out[INPUT_KEY_MAP[k] ?? k] = v;
|
|
52
|
+
// v1 completed tools carry a human title ("Read src/app.tsx"); v2
|
|
53
|
+
// derives titles from inputs — surface ours through `description`
|
|
54
|
+
if (title && out.description === undefined)
|
|
55
|
+
out.description = title;
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
function normalizeToolMetadata(meta) {
|
|
59
|
+
const out = {};
|
|
60
|
+
for (const [k, v] of Object.entries(meta ?? {})) {
|
|
61
|
+
if (k === "filediff")
|
|
62
|
+
out.files = [v];
|
|
63
|
+
else if (k === "sessionId")
|
|
64
|
+
out.sessionID = v;
|
|
65
|
+
else
|
|
66
|
+
out[k] = v;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
function toolContent(output) {
|
|
71
|
+
const text = typeof output === "string" ? output : "";
|
|
72
|
+
return [{ type: "text", text: text || " " }]; // v2 content is NonEmpty
|
|
73
|
+
}
|
|
74
|
+
function normalizeToolState(state) {
|
|
75
|
+
const status = String(state?.status ?? "pending");
|
|
76
|
+
if (status === "pending") {
|
|
77
|
+
return { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(state.input ?? {}) };
|
|
78
|
+
}
|
|
79
|
+
if (status === "running") {
|
|
80
|
+
return { status: "running", input: normalizeToolInput((state.input ?? {}), state.title), metadata: normalizeToolMetadata(state.metadata) };
|
|
81
|
+
}
|
|
82
|
+
if (status === "completed") {
|
|
83
|
+
return { status: "completed", input: normalizeToolInput((state.input ?? {}), state.title), content: toolContent(state.output), metadata: normalizeToolMetadata(state.metadata) };
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
status: "error",
|
|
87
|
+
input: normalizeToolInput((state.input ?? {})),
|
|
88
|
+
error: { type: "unknown", message: typeof state.error === "string" ? state.error : "tool failed" },
|
|
89
|
+
metadata: normalizeToolMetadata(state.metadata),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function normalizeFilePart(p) {
|
|
93
|
+
const url = typeof p.url === "string" ? p.url : "";
|
|
94
|
+
if (!url)
|
|
95
|
+
return undefined;
|
|
96
|
+
const inline = url.startsWith("data:");
|
|
97
|
+
const comma = url.indexOf(",");
|
|
98
|
+
return {
|
|
99
|
+
data: inline ? url.slice(comma + 1) : url,
|
|
100
|
+
mime: typeof p.mime === "string" ? p.mime : "application/octet-stream",
|
|
101
|
+
source: inline ? { type: "inline" } : { type: "uri", uri: url },
|
|
102
|
+
...(typeof p.filename === "string" ? { name: p.filename } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** v1 message + its parts → v2 message. Returns:
|
|
106
|
+
* - { message } — emit as a message item
|
|
107
|
+
* - { refill, summary } — v1 compaction summary assistant: do not emit;
|
|
108
|
+
* rewrite the parent compaction message instead
|
|
109
|
+
* - undefined — nothing to emit (should not happen in practice) */
|
|
110
|
+
export function normalizeV1Message(info, parts) {
|
|
111
|
+
if (!info || typeof info.id !== "string")
|
|
112
|
+
return undefined;
|
|
113
|
+
const id = info.id;
|
|
114
|
+
const created = info.time?.created ?? 0;
|
|
115
|
+
if (info.role === "user") {
|
|
116
|
+
// compaction trigger user message → v2 compaction message (summary
|
|
117
|
+
// refilled when the summary assistant arrives)
|
|
118
|
+
const compaction = parts.find((p) => p?.type === "compaction");
|
|
119
|
+
if (compaction) {
|
|
120
|
+
const message = {
|
|
121
|
+
id,
|
|
122
|
+
type: "compaction",
|
|
123
|
+
status: "completed",
|
|
124
|
+
reason: compaction.auto === false ? "manual" : "auto",
|
|
125
|
+
summary: "",
|
|
126
|
+
recent: "",
|
|
127
|
+
time: { created },
|
|
128
|
+
};
|
|
129
|
+
return { message };
|
|
130
|
+
}
|
|
131
|
+
const text = parts
|
|
132
|
+
.filter((p) => p?.type === "text" && typeof p.text === "string")
|
|
133
|
+
.map((p) => p.text)
|
|
134
|
+
.join("\n");
|
|
135
|
+
const files = parts.filter((p) => p?.type === "file")
|
|
136
|
+
.map(normalizeFilePart)
|
|
137
|
+
.filter((f) => f !== undefined);
|
|
138
|
+
const agents = parts.filter((p) => p?.type === "agent")
|
|
139
|
+
.map((p) => ({ name: String(p.name ?? "") }))
|
|
140
|
+
.filter((a) => a.name);
|
|
141
|
+
const model = info.model;
|
|
142
|
+
const message = {
|
|
143
|
+
id,
|
|
144
|
+
type: "user",
|
|
145
|
+
time: { created },
|
|
146
|
+
text,
|
|
147
|
+
...(files.length ? { files } : {}),
|
|
148
|
+
...(agents.length ? { agents } : {}),
|
|
149
|
+
metadata: {
|
|
150
|
+
...(info.agent !== undefined ? { agent: String(info.agent) } : {}),
|
|
151
|
+
...(model ? { model: { id: String(model.modelID ?? ""), providerID: String(model.providerID ?? "") } } : {}),
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
return { message };
|
|
155
|
+
}
|
|
156
|
+
if (info.role === "assistant") {
|
|
157
|
+
const time = info.time;
|
|
158
|
+
// the v1 compaction summary assistant folds INTO the compaction
|
|
159
|
+
// message that precedes it (its parentID)
|
|
160
|
+
if (info.summary === true && info.mode === "compaction") {
|
|
161
|
+
const summary = parts
|
|
162
|
+
.filter((p) => p?.type === "text" && typeof p.text === "string")
|
|
163
|
+
.map((p) => p.text)
|
|
164
|
+
.join("\n");
|
|
165
|
+
return { refill: String(info.parentID ?? ""), summary };
|
|
166
|
+
}
|
|
167
|
+
const content = [];
|
|
168
|
+
let cost = typeof info.cost === "number" ? info.cost : 0;
|
|
169
|
+
let tokens;
|
|
170
|
+
let retry;
|
|
171
|
+
for (const p of parts) {
|
|
172
|
+
switch (p?.type) {
|
|
173
|
+
case "text":
|
|
174
|
+
content.push({ type: "text", text: String(p.text ?? "") });
|
|
175
|
+
break;
|
|
176
|
+
case "reasoning": {
|
|
177
|
+
const rt = p.time;
|
|
178
|
+
content.push({ type: "reasoning", text: String(p.text ?? ""), time: { created: rt?.start ?? undefined, completed: rt?.end ?? undefined } });
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
case "tool": {
|
|
182
|
+
const st = p.state;
|
|
183
|
+
const tt = (st?.time ?? {});
|
|
184
|
+
content.push({
|
|
185
|
+
type: "tool",
|
|
186
|
+
id: String(p.id ?? ""),
|
|
187
|
+
name: TOOL_NAME_MAP[String(p.tool ?? "unknown")] ?? String(p.tool ?? "unknown"),
|
|
188
|
+
state: normalizeToolState(st ?? {}),
|
|
189
|
+
time: { created: tt.start ?? undefined, completed: tt.end ?? undefined },
|
|
190
|
+
});
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case "retry": {
|
|
194
|
+
const err = p.error;
|
|
195
|
+
retry = {
|
|
196
|
+
attempt: Number(p.attempt ?? 0),
|
|
197
|
+
at: p.time?.created ?? 0,
|
|
198
|
+
error: { type: "unknown", message: String(err?.message ?? err?.name ?? "retry") },
|
|
199
|
+
};
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
case "step-finish": {
|
|
203
|
+
// fold step aggregates into the message
|
|
204
|
+
if (typeof p.cost === "number")
|
|
205
|
+
cost += p.cost;
|
|
206
|
+
if (p.tokens && typeof p.tokens === "object")
|
|
207
|
+
tokens = p.tokens;
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
// step-start / snapshot / patch / subtask / agent / assistant
|
|
211
|
+
// file parts have no v2 content equivalent — dropped by design
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const finish = typeof info.finish === "string" ? info.finish : undefined;
|
|
215
|
+
const errObj = info.error;
|
|
216
|
+
const message = {
|
|
217
|
+
id,
|
|
218
|
+
type: "assistant",
|
|
219
|
+
agent: String(info.agent ?? "build"),
|
|
220
|
+
model: { id: String(info.modelID ?? ""), providerID: String(info.providerID ?? "") },
|
|
221
|
+
time: { created, streamed: time?.started ?? undefined, completed: time?.completed ?? undefined },
|
|
222
|
+
content,
|
|
223
|
+
cost,
|
|
224
|
+
...(tokens ? { tokens } : {}),
|
|
225
|
+
...(finish ? { finish } : {}),
|
|
226
|
+
...(errObj ? { error: { type: String(errObj.name ?? "UnknownError"), message: String(errObj.message ?? "turn failed") } } : {}),
|
|
227
|
+
...(retry ? { retry } : {}),
|
|
228
|
+
};
|
|
229
|
+
return { message };
|
|
230
|
+
}
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
/** v1 session diff (FileDiff.Info[]) → v2 FileDiffInfo[]: the v2 shape
|
|
234
|
+
* REQUIRES numeric additions/deletions (renderers read them unguarded) —
|
|
235
|
+
* compute from the patch when the host didn't provide counts. */
|
|
236
|
+
export function normalizeV1Diff(diff) {
|
|
237
|
+
if (!Array.isArray(diff))
|
|
238
|
+
return diff;
|
|
239
|
+
return diff.map((d) => {
|
|
240
|
+
if (!d || typeof d !== "object")
|
|
241
|
+
return d;
|
|
242
|
+
const r = d;
|
|
243
|
+
if (typeof r.additions === "number" && typeof r.deletions === "number")
|
|
244
|
+
return r;
|
|
245
|
+
const patch = typeof r.patch === "string" ? r.patch : "";
|
|
246
|
+
let additions = 0;
|
|
247
|
+
let deletions = 0;
|
|
248
|
+
for (const line of patch.split("\n")) {
|
|
249
|
+
if (line.startsWith("+") && !line.startsWith("+++"))
|
|
250
|
+
additions++;
|
|
251
|
+
else if (line.startsWith("-") && !line.startsWith("---"))
|
|
252
|
+
deletions++;
|
|
253
|
+
}
|
|
254
|
+
return { ...r, additions: typeof r.additions === "number" ? r.additions : additions, deletions: typeof r.deletions === "number" ? r.deletions : deletions };
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/** v1 hosts paginate messages with an opaque cursor: base64url of
|
|
258
|
+
* {id, time} where time = the page tail's time_created (message-v2.ts
|
|
259
|
+
* cursor codec). A bare message id is rejected with 400 — that silently
|
|
260
|
+
* truncated every v1 backfill to one page (live-caught). */
|
|
261
|
+
export function cursorFor(id, time) {
|
|
262
|
+
return Buffer.from(JSON.stringify({ id, time })).toString("base64url");
|
|
263
|
+
}
|