opencode-sidebar 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.
@@ -0,0 +1,366 @@
1
+ import net from "node:net";
2
+ import { spawn } from "node:child_process";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { createOpencodeClient } from "@opencode-ai/sdk/v2";
6
+ import { DEFAULT_PORT, PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE, SERVER_HOST, SERVER_LOG_FILE, SESSION_PAGE_LIMIT, SNAPSHOT_DEBOUNCE_MS, } from "./constants.js";
7
+ import { buildSnapshot } from "./model.js";
8
+ import { SoundNotifier } from "./notifications.js";
9
+ import { loadState, saveState, updateState } from "./state.js";
10
+ import { assertDirectoryExists, normalizeDirectory, sleep } from "./util.js";
11
+ import { describeTerminalBackend, getPreviewSessionID, killSessionWindow, listActiveSessions, openSessionWithPreferredTerminal, retitleSessionWindow, } from "./terminal.js";
12
+ const RECENT_COMPLETION_WINDOW_MS = 5 * 60_000;
13
+ async function isHealthy(port) {
14
+ try {
15
+ const response = await fetch(`http://${SERVER_HOST}:${port}/global/health`);
16
+ if (!response.ok)
17
+ return false;
18
+ const json = (await response.json());
19
+ return json.healthy === true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ async function isPortAvailable(port) {
26
+ return new Promise((resolve) => {
27
+ const server = net.createServer();
28
+ server.once("error", () => resolve(false));
29
+ server.once("listening", () => {
30
+ server.close(() => resolve(true));
31
+ });
32
+ server.listen(port, SERVER_HOST);
33
+ });
34
+ }
35
+ async function nextFreePort(start) {
36
+ for (let port = start; port < start + 50; port++) {
37
+ if (await isPortAvailable(port))
38
+ return port;
39
+ }
40
+ throw new Error("Could not find a free local port for opencode serve");
41
+ }
42
+ async function startDetachedServer(port) {
43
+ await fs.mkdir(path.dirname(SERVER_LOG_FILE), { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
44
+ await fs.chmod(path.dirname(SERVER_LOG_FILE), PRIVATE_DIRECTORY_MODE).catch(() => { });
45
+ const handle = await fs.open(SERVER_LOG_FILE, "a", PRIVATE_FILE_MODE);
46
+ await fs.chmod(SERVER_LOG_FILE, PRIVATE_FILE_MODE).catch(() => { });
47
+ const child = spawn("opencode", ["serve", "--hostname", SERVER_HOST, "--port", String(port)], {
48
+ detached: true,
49
+ stdio: ["ignore", handle.fd, handle.fd],
50
+ });
51
+ child.unref();
52
+ await handle.close();
53
+ }
54
+ async function ensureServerPort() {
55
+ const state = await loadState();
56
+ let port = state.serverPort || DEFAULT_PORT;
57
+ if (await isHealthy(port))
58
+ return port;
59
+ if (!(await isPortAvailable(port))) {
60
+ port = await nextFreePort(Math.max(DEFAULT_PORT, port + 1));
61
+ }
62
+ if (port !== state.serverPort) {
63
+ await saveState({ ...state, serverPort: port });
64
+ }
65
+ await startDetachedServer(port);
66
+ const started = Date.now();
67
+ while (Date.now() - started < 20_000) {
68
+ if (await isHealthy(port))
69
+ return port;
70
+ await sleep(250);
71
+ }
72
+ throw new Error(`Timed out waiting for opencode serve on port ${port}`);
73
+ }
74
+ async function fetchAllSessions(client) {
75
+ const sessions = [];
76
+ let cursor;
77
+ while (sessions.length < 2000) {
78
+ const result = await client.experimental.session.list({
79
+ roots: true,
80
+ limit: SESSION_PAGE_LIMIT,
81
+ archived: false,
82
+ cursor,
83
+ });
84
+ const chunk = (result.data ?? []);
85
+ sessions.push(...chunk);
86
+ const next = result.response.headers.get("x-next-cursor");
87
+ if (!next)
88
+ break;
89
+ cursor = Number(next);
90
+ if (!Number.isFinite(cursor))
91
+ break;
92
+ }
93
+ return sessions;
94
+ }
95
+ async function fetchProjects(client) {
96
+ const result = await client.project.list();
97
+ return (result.data ?? []).map((project) => ({
98
+ id: project.id,
99
+ name: project.name,
100
+ worktree: project.worktree,
101
+ sandboxes: project.sandboxes ?? [],
102
+ }));
103
+ }
104
+ async function fetchSessionStatuses(client) {
105
+ const result = await client.session.status();
106
+ return (result.data ?? {});
107
+ }
108
+ async function fetchPendingQuestions(client) {
109
+ const result = await client.question.list();
110
+ return result.data ?? [];
111
+ }
112
+ async function fetchPendingPermissions(client) {
113
+ const result = await client.permission.list();
114
+ return result.data ?? [];
115
+ }
116
+ function mergeSessionStatuses(sessions, statuses) {
117
+ const now = Date.now();
118
+ return sessions.map((session) => {
119
+ const status = statuses[session.id];
120
+ if (status) {
121
+ return {
122
+ ...session,
123
+ status,
124
+ };
125
+ }
126
+ const justCompleted = now - session.time.updated <= RECENT_COMPLETION_WINDOW_MS;
127
+ return {
128
+ ...session,
129
+ status: {
130
+ type: "idle",
131
+ justCompleted,
132
+ },
133
+ };
134
+ });
135
+ }
136
+ export class LauncherService {
137
+ client;
138
+ baseUrl;
139
+ port;
140
+ readyPromise;
141
+ snapshotPromise;
142
+ launchedSessionIDs = new Set();
143
+ notifier = new SoundNotifier();
144
+ async ensureReady() {
145
+ if (this.client && this.baseUrl && this.port && (await isHealthy(this.port))) {
146
+ return {
147
+ client: this.client,
148
+ baseUrl: this.baseUrl,
149
+ port: this.port,
150
+ };
151
+ }
152
+ if (this.readyPromise) {
153
+ return this.readyPromise;
154
+ }
155
+ this.readyPromise = (async () => {
156
+ this.client = undefined;
157
+ this.baseUrl = undefined;
158
+ this.port = undefined;
159
+ const port = await ensureServerPort();
160
+ const baseUrl = `http://${SERVER_HOST}:${port}`;
161
+ const client = createOpencodeClient({ baseUrl });
162
+ this.client = client;
163
+ this.baseUrl = baseUrl;
164
+ this.port = port;
165
+ return {
166
+ client,
167
+ baseUrl,
168
+ port,
169
+ };
170
+ })();
171
+ try {
172
+ return await this.readyPromise;
173
+ }
174
+ finally {
175
+ this.readyPromise = undefined;
176
+ }
177
+ }
178
+ async getSnapshot() {
179
+ if (this.snapshotPromise) {
180
+ return this.snapshotPromise;
181
+ }
182
+ this.snapshotPromise = (async () => {
183
+ const [{ client, baseUrl, port }, state, activeSessions, previewSessionID] = await Promise.all([
184
+ this.ensureReady(),
185
+ loadState(),
186
+ listActiveSessions(),
187
+ getPreviewSessionID(),
188
+ ]);
189
+ const [projects, sessions, statuses, questions, permissions] = await Promise.all([
190
+ fetchProjects(client),
191
+ fetchAllSessions(client),
192
+ fetchSessionStatuses(client),
193
+ fetchPendingQuestions(client),
194
+ fetchPendingPermissions(client),
195
+ ]);
196
+ const snapshot = buildSnapshot({
197
+ baseUrl,
198
+ serverPort: port,
199
+ projects,
200
+ sessions: mergeSessionStatuses(sessions, statuses),
201
+ pinnedDirectories: state.pinnedDirectories,
202
+ panes: [],
203
+ activeSessions,
204
+ previewSessionID,
205
+ });
206
+ this.notifier.syncSnapshot(snapshot);
207
+ this.notifier.syncPendingRequests({
208
+ questions,
209
+ permissions,
210
+ });
211
+ return snapshot;
212
+ })();
213
+ try {
214
+ return await this.snapshotPromise;
215
+ }
216
+ finally {
217
+ this.snapshotPromise = undefined;
218
+ }
219
+ }
220
+ async addProjectDirectory(rawDirectory) {
221
+ const { client } = await this.ensureReady();
222
+ const directory = normalizeDirectory(rawDirectory);
223
+ await assertDirectoryExists(directory);
224
+ try {
225
+ await client.project.current({ directory });
226
+ }
227
+ catch {
228
+ // Some folders may not have been opened in OpenCode yet. Persisting the path
229
+ // still lets the sidebar surface it so a first session can be created there.
230
+ }
231
+ await updateState((state) => ({
232
+ ...state,
233
+ pinnedDirectories: state.pinnedDirectories.includes(directory)
234
+ ? state.pinnedDirectories
235
+ : [...state.pinnedDirectories, directory],
236
+ }));
237
+ return directory;
238
+ }
239
+ async pinDirectory(rawDirectory) {
240
+ return this.addProjectDirectory(rawDirectory);
241
+ }
242
+ async unpinDirectory(directory) {
243
+ await updateState((state) => ({
244
+ ...state,
245
+ pinnedDirectories: state.pinnedDirectories.filter((item) => item !== directory),
246
+ }));
247
+ }
248
+ async openSession(directory, session) {
249
+ const { baseUrl } = await this.ensureReady();
250
+ const result = await openSessionWithPreferredTerminal({
251
+ sessionID: session.id,
252
+ directory,
253
+ title: session.title,
254
+ baseUrl,
255
+ });
256
+ this.launchedSessionIDs.add(session.id);
257
+ return result;
258
+ }
259
+ async openNewSession(directory) {
260
+ const { client, baseUrl } = await this.ensureReady();
261
+ const result = await client.session.create({ directory });
262
+ if (!result.data) {
263
+ throw new Error("Failed to create a new session");
264
+ }
265
+ const session = result.data;
266
+ const openResult = await openSessionWithPreferredTerminal({
267
+ sessionID: session.id,
268
+ directory,
269
+ title: session.title,
270
+ baseUrl,
271
+ });
272
+ this.launchedSessionIDs.add(session.id);
273
+ return openResult;
274
+ }
275
+ async openDirectory(record) {
276
+ if (record.sessions.length > 0) {
277
+ return this.openSession(record.directory, record.sessions[0]);
278
+ }
279
+ return this.openNewSession(record.directory);
280
+ }
281
+ async deleteSession(directory, sessionID) {
282
+ const { client } = await this.ensureReady();
283
+ await client.session.delete({
284
+ sessionID,
285
+ directory,
286
+ });
287
+ }
288
+ async renameSession(directory, sessionID, title) {
289
+ const { client } = await this.ensureReady();
290
+ const nextTitle = title.trim();
291
+ if (!nextTitle) {
292
+ throw new Error("Session title is empty");
293
+ }
294
+ await client.session.update({
295
+ sessionID,
296
+ directory,
297
+ title: nextTitle,
298
+ });
299
+ await retitleSessionWindow(sessionID, directory, nextTitle).catch(() => false);
300
+ return nextTitle;
301
+ }
302
+ async killSession(sessionID) {
303
+ const killed = await killSessionWindow(sessionID);
304
+ if (killed) {
305
+ this.launchedSessionIDs.delete(sessionID);
306
+ }
307
+ return killed;
308
+ }
309
+ async shutdown() {
310
+ const launchedSessionIDs = [...this.launchedSessionIDs];
311
+ const results = [];
312
+ for (const sessionID of launchedSessionIDs) {
313
+ results.push(await killSessionWindow(sessionID).catch(() => false));
314
+ }
315
+ const deadline = Date.now() + 3_000;
316
+ while (Date.now() < deadline) {
317
+ const activeSessionIDs = new Set((await listActiveSessions().catch(() => [])).map((session) => session.sessionID));
318
+ if (launchedSessionIDs.every((sessionID) => !activeSessionIDs.has(sessionID))) {
319
+ break;
320
+ }
321
+ await sleep(100);
322
+ }
323
+ this.launchedSessionIDs.clear();
324
+ return results;
325
+ }
326
+ async subscribe(signal, onInvalidate) {
327
+ const { client } = await this.ensureReady();
328
+ let timer;
329
+ const invalidate = () => {
330
+ if (timer)
331
+ clearTimeout(timer);
332
+ timer = setTimeout(() => {
333
+ if (!signal.aborted)
334
+ onInvalidate();
335
+ }, SNAPSHOT_DEBOUNCE_MS);
336
+ };
337
+ signal.addEventListener("abort", () => {
338
+ if (timer)
339
+ clearTimeout(timer);
340
+ }, { once: true });
341
+ (async () => {
342
+ while (!signal.aborted) {
343
+ try {
344
+ const events = await client.global.event({ signal });
345
+ for await (const event of events.stream) {
346
+ if (signal.aborted)
347
+ break;
348
+ this.notifier.handleEvent({
349
+ directory: event.directory,
350
+ event: event.payload,
351
+ });
352
+ invalidate();
353
+ }
354
+ }
355
+ catch {
356
+ if (signal.aborted)
357
+ break;
358
+ await sleep(1000);
359
+ }
360
+ }
361
+ })().catch(() => { });
362
+ }
363
+ describeBackend() {
364
+ return describeTerminalBackend();
365
+ }
366
+ }
@@ -0,0 +1,47 @@
1
+ import fs from "node:fs/promises";
2
+ import { APP_DIR, DEFAULT_PORT, PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE, STATE_FILE } from "./constants.js";
3
+ import { distinct } from "./util.js";
4
+ const DEFAULT_STATE = {
5
+ serverPort: DEFAULT_PORT,
6
+ pinnedDirectories: [],
7
+ };
8
+ async function ensureAppDir() {
9
+ await fs.mkdir(APP_DIR, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
10
+ await fs.chmod(APP_DIR, PRIVATE_DIRECTORY_MODE).catch(() => { });
11
+ }
12
+ export async function loadState() {
13
+ await ensureAppDir();
14
+ try {
15
+ const raw = await fs.readFile(STATE_FILE, "utf8");
16
+ const parsed = JSON.parse(raw);
17
+ return {
18
+ serverPort: parsed.serverPort && Number.isInteger(parsed.serverPort) ? parsed.serverPort : DEFAULT_PORT,
19
+ pinnedDirectories: distinct((parsed.pinnedDirectories ?? []).filter((item) => typeof item === "string")),
20
+ };
21
+ }
22
+ catch {
23
+ return { ...DEFAULT_STATE };
24
+ }
25
+ }
26
+ export async function saveState(state) {
27
+ await ensureAppDir();
28
+ const normalized = {
29
+ serverPort: state.serverPort,
30
+ pinnedDirectories: distinct(state.pinnedDirectories),
31
+ };
32
+ await fs.writeFile(STATE_FILE, JSON.stringify(normalized, null, 2) + "\n", {
33
+ encoding: "utf8",
34
+ mode: PRIVATE_FILE_MODE,
35
+ });
36
+ await fs.chmod(STATE_FILE, PRIVATE_FILE_MODE).catch(() => { });
37
+ }
38
+ export async function updateState(updater) {
39
+ const current = await loadState();
40
+ const next = await updater(current);
41
+ await saveState(next);
42
+ return next;
43
+ }
44
+ export async function touchAppDir() {
45
+ await ensureAppDir();
46
+ return APP_DIR;
47
+ }
@@ -0,0 +1,106 @@
1
+ import { ensureTmuxLayout, findAnyWindowBySessionID, findBackgroundWindowBySessionID, getPreviewSessionMeta, isTmux, killSessionWindowBySessionID, listActiveSessionWindows, parkPreviewSession, pruneBackgroundSessions, renameSessionWindow, respawnPane, setPaneSession, setPreviewSession, setPaneTitle, swapPreviewWithSessionPane, } from "./tmux.js";
2
+ import { sessionWindowTitle } from "./util.js";
3
+ export function describeTerminalBackend() {
4
+ if (!isTmux()) {
5
+ throw new Error("This launcher now supports tmux mode only");
6
+ }
7
+ return "tmux";
8
+ }
9
+ export async function listActiveSessions() {
10
+ return listActiveSessionWindows();
11
+ }
12
+ export async function hasRunningSessionWindow(sessionID) {
13
+ return Boolean(await findAnyWindowBySessionID(sessionID));
14
+ }
15
+ export async function killSessionWindow(sessionID) {
16
+ return killSessionWindowBySessionID(sessionID);
17
+ }
18
+ export async function retitleSessionWindow(sessionID, directory, title) {
19
+ return renameSessionWindow(sessionID, directory, title);
20
+ }
21
+ export async function cleanupSidebarSessions(sessionIDs) {
22
+ const results = [];
23
+ for (const sessionID of sessionIDs) {
24
+ results.push(await killSessionWindowBySessionID(sessionID).catch(() => false));
25
+ }
26
+ return results;
27
+ }
28
+ export async function getPreviewSessionID() {
29
+ const preview = await getPreviewSessionMeta();
30
+ return preview?.sessionID;
31
+ }
32
+ export async function openSessionWithPreferredTerminal(input) {
33
+ let { rightPaneID } = await ensureTmuxLayout(input.directory);
34
+ const previewSession = await getPreviewSessionMeta();
35
+ if (previewSession?.paneID) {
36
+ rightPaneID = previewSession.paneID;
37
+ }
38
+ if (previewSession?.sessionID === input.sessionID) {
39
+ return {
40
+ action: "focused",
41
+ sessionID: input.sessionID,
42
+ backend: "tmux",
43
+ };
44
+ }
45
+ const parkedSession = await findBackgroundWindowBySessionID(input.sessionID);
46
+ if (parkedSession) {
47
+ await swapPreviewWithSessionPane({
48
+ previewPaneID: rightPaneID,
49
+ sessionPaneID: parkedSession.paneID,
50
+ hiddenWindowID: parkedSession.windowID,
51
+ previewSession,
52
+ nextSession: {
53
+ sessionID: input.sessionID,
54
+ directory: input.directory,
55
+ title: input.title,
56
+ },
57
+ });
58
+ await pruneBackgroundSessions({
59
+ keepSessionIDs: [input.sessionID],
60
+ });
61
+ return {
62
+ action: "focused",
63
+ sessionID: input.sessionID,
64
+ backend: "tmux",
65
+ };
66
+ }
67
+ if (previewSession && previewSession.sessionID !== input.sessionID) {
68
+ await parkPreviewSession({
69
+ previewPaneID: rightPaneID,
70
+ sessionID: previewSession.sessionID,
71
+ directory: previewSession.directory,
72
+ title: previewSession.title,
73
+ });
74
+ rightPaneID = (await ensureTmuxLayout(input.directory)).rightPaneID;
75
+ }
76
+ await respawnPane(rightPaneID, input.directory, [
77
+ "opencode",
78
+ "attach",
79
+ input.baseUrl,
80
+ "--dir",
81
+ input.directory,
82
+ "--session",
83
+ input.sessionID,
84
+ ]);
85
+ await setPaneSession({
86
+ paneID: rightPaneID,
87
+ sessionID: input.sessionID,
88
+ directory: input.directory,
89
+ title: input.title,
90
+ });
91
+ await setPaneTitle(rightPaneID, sessionWindowTitle(input.directory, input.title));
92
+ await setPreviewSession({
93
+ sessionID: input.sessionID,
94
+ directory: input.directory,
95
+ title: input.title,
96
+ paneID: rightPaneID,
97
+ });
98
+ await pruneBackgroundSessions({
99
+ keepSessionIDs: [input.sessionID],
100
+ });
101
+ return {
102
+ action: "focused",
103
+ sessionID: input.sessionID,
104
+ backend: "tmux",
105
+ };
106
+ }