@tt-a1i/openpi 0.3.1 → 0.5.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.
Files changed (129) hide show
  1. package/README.md +184 -59
  2. package/SETUP.md +23 -7
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/ask-user/index.ts +30 -14
  6. package/extensions/background-terminals/index.ts +30 -2
  7. package/extensions/background-terminals/src/domain.ts +2 -0
  8. package/extensions/background-terminals/src/manager.ts +486 -106
  9. package/extensions/background-terminals/src/output.ts +33 -0
  10. package/extensions/background-terminals/src/prompt.ts +14 -6
  11. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  12. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  13. package/extensions/capabilities/index.ts +30 -42
  14. package/extensions/capabilities/src/ui.ts +93 -0
  15. package/extensions/clear-context/index.ts +83 -0
  16. package/extensions/context-pivot/index.ts +16 -6
  17. package/extensions/cron/schedule.ts +7 -1
  18. package/extensions/file-mutation-display/index.ts +34 -76
  19. package/extensions/file-mutation-display/render.ts +146 -87
  20. package/extensions/file-search/index.ts +8 -7
  21. package/extensions/file-search/src/binaries.ts +75 -59
  22. package/extensions/git-info/src/changed-files-view.ts +47 -14
  23. package/extensions/git-read/index.ts +328 -0
  24. package/extensions/git-read/src/args.ts +171 -0
  25. package/extensions/git-read/src/process.ts +81 -0
  26. package/extensions/git-read/src/prompt.ts +56 -0
  27. package/extensions/model-info/index.ts +21 -33
  28. package/extensions/model-info/session-metrics.ts +96 -0
  29. package/extensions/plan-mode/bash-policy.ts +54 -9
  30. package/extensions/plan-mode/index.ts +7 -2
  31. package/extensions/post-edit/index.ts +16 -6
  32. package/extensions/sessions/git-stats.ts +258 -72
  33. package/extensions/sessions/index.ts +222 -140
  34. package/extensions/sessions/preview-cache.ts +104 -0
  35. package/extensions/sessions/preview-loader.ts +856 -0
  36. package/extensions/sessions/sessions.ts +43 -4
  37. package/extensions/setup/index.ts +127 -131
  38. package/extensions/shared/activity-status.ts +36 -5
  39. package/extensions/shared/agent-session-page.ts +319 -0
  40. package/extensions/shared/agent-tool-renderer.ts +218 -0
  41. package/extensions/shared/agent-transcript.ts +524 -0
  42. package/extensions/shared/below-editor-navigation.ts +26 -0
  43. package/extensions/shared/capability-intent.ts +53 -0
  44. package/extensions/shared/child-session.ts +444 -22
  45. package/extensions/shared/result-budget.ts +134 -0
  46. package/extensions/shared/result-delivery.ts +34 -0
  47. package/extensions/shared/screen-chrome.ts +133 -0
  48. package/extensions/shared/setup-config.ts +97 -38
  49. package/extensions/shared/setup-episode-state.ts +1 -1
  50. package/extensions/shared/spinner.ts +28 -0
  51. package/extensions/shared/terminal-text.ts +110 -23
  52. package/extensions/shared/text-projection.ts +113 -0
  53. package/extensions/shared/tool-activity.ts +382 -0
  54. package/extensions/shared/tool-surface.ts +42 -8
  55. package/extensions/shared/transcript-viewport.ts +46 -0
  56. package/extensions/shared/web-observer-registry.ts +390 -0
  57. package/extensions/shared/worktree.ts +11 -0
  58. package/extensions/subagents/index.ts +461 -186
  59. package/extensions/subagents/navigation.ts +86 -28
  60. package/extensions/subagents/src/agent-types.ts +37 -15
  61. package/extensions/subagents/src/backend.ts +12 -1
  62. package/extensions/subagents/src/backends/pi.ts +375 -66
  63. package/extensions/subagents/src/domain.ts +5 -0
  64. package/extensions/subagents/src/id-sequence.ts +84 -0
  65. package/extensions/subagents/src/manager.ts +651 -536
  66. package/extensions/subagents/src/prompt.ts +185 -42
  67. package/extensions/subagents/src/result-artifact.ts +146 -0
  68. package/extensions/subagents/src/result-delivery.ts +7 -1
  69. package/extensions/subagents/src/runtime.ts +23 -6
  70. package/extensions/subagents/src/ui/takeover.ts +128 -337
  71. package/extensions/subagents/src/ui/transcript.ts +38 -501
  72. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  73. package/extensions/suggestions/src/ui.ts +10 -4
  74. package/extensions/tasks/index.ts +0 -3
  75. package/extensions/tasks/ui.ts +79 -62
  76. package/extensions/ui-customization/footer.ts +7 -44
  77. package/extensions/ui-customization/index.ts +0 -4
  78. package/extensions/user-input-fold/index.ts +185 -0
  79. package/extensions/web/index.ts +234 -0
  80. package/extensions/workflows/artifacts.ts +147 -22
  81. package/extensions/workflows/completion-projection.ts +457 -0
  82. package/extensions/workflows/controller.ts +14 -2
  83. package/extensions/workflows/coordinator.ts +62 -0
  84. package/extensions/workflows/dashboard.ts +458 -339
  85. package/extensions/workflows/handoff.ts +121 -25
  86. package/extensions/workflows/index.ts +1042 -492
  87. package/extensions/workflows/journal.ts +148 -13
  88. package/extensions/workflows/model.ts +131 -19
  89. package/extensions/workflows/navigation.ts +61 -18
  90. package/extensions/workflows/progress-projection.ts +306 -0
  91. package/extensions/workflows/prompt.ts +166 -10
  92. package/extensions/workflows/replay-safety.ts +58 -27
  93. package/extensions/workflows/result-delivery.ts +253 -0
  94. package/extensions/workflows/retention.ts +593 -0
  95. package/extensions/workflows/runner.ts +388 -279
  96. package/extensions/workflows/sandbox-child.cjs +36 -3
  97. package/extensions/workflows/sandbox.ts +62 -8
  98. package/extensions/workflows/serialization.ts +325 -17
  99. package/extensions/workflows/tool-renderer.ts +22 -0
  100. package/extensions/workflows/transcript.ts +149 -0
  101. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  102. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  103. package/package.json +28 -8
  104. package/skills/subagents/REFERENCE.md +189 -0
  105. package/skills/subagents/SKILL.md +2 -2
  106. package/skills/workflows/REFERENCE.md +10 -5
  107. package/skills/workflows/SKILL.md +53 -10
  108. package/web/adapter/pi-adapter.ts +661 -0
  109. package/web/host/browser-launcher.ts +20 -0
  110. package/web/host/static-assets.ts +4 -0
  111. package/web/host/terminal-status.ts +38 -0
  112. package/web/host/web-host.ts +789 -0
  113. package/web/http-dispatcher.ts +125 -0
  114. package/web/protocol/types.ts +462 -0
  115. package/web/runtime/pi-runtime.ts +991 -0
  116. package/web/runtime/types.ts +71 -0
  117. package/web/runtime/web-host-lease.ts +497 -0
  118. package/web/trace.ts +18 -0
  119. package/web/ui/app.js +1398 -0
  120. package/web/ui/index.html +139 -0
  121. package/web/ui/styles.css +598 -0
  122. package/web/vite.config.mjs +34 -0
  123. package/extensions/execution-convergence/active-evidence.ts +0 -129
  124. package/extensions/execution-convergence/index.ts +0 -442
  125. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  126. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  127. package/extensions/setup/intercom.ts +0 -603
  128. package/extensions/subagents/src/backends/stub.ts +0 -296
  129. package/extensions/subagents/src/format.ts +0 -48
@@ -0,0 +1,661 @@
1
+ import { readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
4
+ import { webCapabilitySnapshot } from "../../extensions/shared/web-observer-registry.ts";
5
+ import {
6
+ boundedText,
7
+ jsonByteLength,
8
+ projectEntries,
9
+ projectEntry,
10
+ WEB_MAX_MODELS,
11
+ WEB_MAX_SESSIONS,
12
+ WEB_MAX_SESSION_PREVIEW,
13
+ WEB_MAX_SNAPSHOT_BYTES,
14
+ WEB_MAX_WORKSPACES,
15
+ type WebSessionProjection,
16
+ type WebSessionSummary,
17
+ type WebSnapshotTruncation,
18
+ type WebWorkspaceSummary,
19
+ } from "../protocol/types.ts";
20
+ import type { WebRuntimeController } from "../runtime/types.ts";
21
+
22
+ type WorkspaceStateSnapshot = {
23
+ importedWorkspaces: Set<string>;
24
+ hiddenWorkspaces: Set<string>;
25
+ workspaceNames: Map<string, string>;
26
+ ungroupedSessions: Set<string>;
27
+ restoreInitialWorkspace: boolean;
28
+ };
29
+
30
+ export class PiWebAdapter {
31
+ private readonly runtime: WebRuntimeController;
32
+ private readonly importedWorkspaces = new Set<string>();
33
+ private readonly hiddenWorkspaces = new Set<string>();
34
+ private readonly workspaceNames = new Map<string, string>();
35
+ private readonly workspaceStateFile: string;
36
+ private readonly ungroupedSessions = new Set<string>();
37
+ private readonly archiveFile: string;
38
+ private readonly archivedSessions = new Set<string>();
39
+ private archivesLoaded = false;
40
+ private archivesLoading?: Promise<void>;
41
+ private archivesWrite: Promise<void> = Promise.resolve();
42
+ private workspaceStateLoaded = false;
43
+ private workspaceStateLoading?: Promise<void>;
44
+ private workspaceStateWrite: Promise<void> = Promise.resolve();
45
+ private initialization?: Promise<void>;
46
+ private restoreInitialWorkspace = false;
47
+ private writeSequence = 0;
48
+
49
+ constructor(runtime: WebRuntimeController) {
50
+ this.runtime = runtime;
51
+ this.workspaceStateFile = join(
52
+ runtime.sessionDirectory,
53
+ "workspace-state.json",
54
+ );
55
+ this.archiveFile = join(runtime.sessionDirectory, "archived-sessions.json");
56
+ if (runtime.workspaceSelected === true) {
57
+ this.importedWorkspaces.add(resolve(runtime.cwd));
58
+ }
59
+ }
60
+
61
+ private async ensureWorkspaceStateLoaded() {
62
+ if (this.workspaceStateLoaded) return;
63
+ if (!this.workspaceStateLoading) {
64
+ this.workspaceStateLoading = (async () => {
65
+ try {
66
+ const parsed: unknown = JSON.parse(
67
+ await readFile(this.workspaceStateFile, "utf8"),
68
+ );
69
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
70
+ throw new Error("Workspace metadata must be an object");
71
+ }
72
+ const state = parsed as Record<string, unknown>;
73
+ if (
74
+ !Array.isArray(state.hiddenWorkspaces) ||
75
+ !state.hiddenWorkspaces.every((path) => typeof path === "string") ||
76
+ !Array.isArray(state.ungroupedSessions) ||
77
+ !state.ungroupedSessions.every((path) => typeof path === "string") ||
78
+ !state.workspaceNames ||
79
+ typeof state.workspaceNames !== "object" ||
80
+ Array.isArray(state.workspaceNames) ||
81
+ !Object.entries(state.workspaceNames).every(
82
+ ([path, name]) => path.length > 0 && typeof name === "string",
83
+ )
84
+ ) {
85
+ throw new Error("Workspace metadata has an invalid shape");
86
+ }
87
+ for (const path of state.hiddenWorkspaces) {
88
+ this.hiddenWorkspaces.add(resolve(path));
89
+ }
90
+ for (const path of state.ungroupedSessions) {
91
+ this.ungroupedSessions.add(resolve(path));
92
+ }
93
+ for (const [path, name] of Object.entries(state.workspaceNames)) {
94
+ this.workspaceNames.set(resolve(path), name as string);
95
+ }
96
+ } catch (error) {
97
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
98
+ }
99
+ this.restoreInitialWorkspace =
100
+ this.runtime.workspaceSelected === true &&
101
+ this.hiddenWorkspaces.has(resolve(this.runtime.cwd));
102
+ this.workspaceStateLoaded = true;
103
+ })();
104
+ }
105
+ await this.workspaceStateLoading;
106
+ }
107
+
108
+ private enqueueWorkspaceMutation<T>(
109
+ mutate: (draft: WorkspaceStateSnapshot) => T | Promise<T>,
110
+ ) {
111
+ const operation = this.workspaceStateWrite.then(async () => {
112
+ const draft = this.captureWorkspaceState();
113
+ const result = await mutate(draft);
114
+ await this.writeAtomically(
115
+ this.workspaceStateFile,
116
+ `${JSON.stringify({
117
+ version: 1,
118
+ hiddenWorkspaces: [...draft.hiddenWorkspaces],
119
+ ungroupedSessions: [...draft.ungroupedSessions],
120
+ workspaceNames: Object.fromEntries(draft.workspaceNames),
121
+ })}\n`,
122
+ );
123
+ this.restoreWorkspaceState(draft);
124
+ return result;
125
+ });
126
+ this.workspaceStateWrite = operation.then(
127
+ () => undefined,
128
+ () => undefined,
129
+ );
130
+ return operation;
131
+ }
132
+
133
+ private async ensureArchivesLoaded() {
134
+ if (this.archivesLoaded) return;
135
+ if (!this.archivesLoading) {
136
+ this.archivesLoading = (async () => {
137
+ try {
138
+ const parsed: unknown = JSON.parse(
139
+ await readFile(this.archiveFile, "utf8"),
140
+ );
141
+ if (
142
+ !Array.isArray(parsed) ||
143
+ !parsed.every((path) => typeof path === "string")
144
+ ) {
145
+ throw new Error("Archive metadata must be an array of paths");
146
+ }
147
+ for (const path of parsed) this.archivedSessions.add(resolve(path));
148
+ } catch (error) {
149
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
150
+ }
151
+ this.archivesLoaded = true;
152
+ })();
153
+ }
154
+ await this.archivesLoading;
155
+ }
156
+
157
+ private enqueueArchiveMutation<T>(
158
+ mutate: (draft: Set<string>) => T | Promise<T>,
159
+ ) {
160
+ const operation = this.archivesWrite.then(async () => {
161
+ const draft = new Set(this.archivedSessions);
162
+ const result = await mutate(draft);
163
+ await this.writeAtomically(
164
+ this.archiveFile,
165
+ `${JSON.stringify([...draft])}\n`,
166
+ );
167
+ this.archivedSessions.clear();
168
+ for (const archivedPath of draft) {
169
+ this.archivedSessions.add(archivedPath);
170
+ }
171
+ return result;
172
+ });
173
+ this.archivesWrite = operation.then(
174
+ () => undefined,
175
+ () => undefined,
176
+ );
177
+ return operation;
178
+ }
179
+
180
+ private async writeAtomically(filePath: string, content: string) {
181
+ const temporary = `${filePath}.tmp-${process.pid}-${++this.writeSequence}`;
182
+ try {
183
+ await writeFile(temporary, content, "utf8");
184
+ await rename(temporary, filePath);
185
+ } finally {
186
+ await rm(temporary, { force: true });
187
+ }
188
+ }
189
+
190
+ initialize() {
191
+ this.initialization ??= (async () => {
192
+ await this.ensureWorkspaceStateLoaded();
193
+ if (!this.restoreInitialWorkspace) return;
194
+ await this.enqueueWorkspaceMutation((draft) => {
195
+ draft.restoreInitialWorkspace = false;
196
+ draft.hiddenWorkspaces.delete(resolve(this.runtime.cwd));
197
+ draft.importedWorkspaces.add(resolve(this.runtime.cwd));
198
+ });
199
+ })();
200
+ return this.initialization;
201
+ }
202
+
203
+ async importWorkspace(path: string) {
204
+ await this.ensureWorkspaceStateLoaded();
205
+ const canonical = await realpath(resolve(path));
206
+ const info = await stat(canonical);
207
+ if (!info.isDirectory())
208
+ throw new Error("Workspace path is not a directory");
209
+ return this.enqueueWorkspaceMutation((draft) => {
210
+ draft.importedWorkspaces.add(canonical);
211
+ draft.hiddenWorkspaces.delete(canonical);
212
+ return canonical;
213
+ });
214
+ }
215
+
216
+ async requireWorkspace(path: string) {
217
+ await this.ensureWorkspaceStateLoaded();
218
+ const canonical = resolve(path);
219
+ const workspaces = await this.workspacePaths();
220
+ if (!workspaces.has(canonical) || this.hiddenWorkspaces.has(canonical)) {
221
+ throw new Error("Workspace is not available");
222
+ }
223
+ return canonical;
224
+ }
225
+
226
+ async requireSession(path: string) {
227
+ await this.ensureWorkspaceStateLoaded();
228
+ const sessions = await this.listSessions(path);
229
+ const session = sessions.find((item) => item.path === path);
230
+ if (!session) throw new Error("Session is not available");
231
+ if (!session.ungrouped) {
232
+ const workspace = resolve(session.cwd);
233
+ if (this.hiddenWorkspaces.has(workspace)) {
234
+ throw new Error("Workspace is not available");
235
+ }
236
+ }
237
+ return session;
238
+ }
239
+
240
+ async renameWorkspace(path: string, name: string) {
241
+ await this.ensureWorkspaceStateLoaded();
242
+ const canonical = resolve(path);
243
+ const normalized = name.trim();
244
+ if (
245
+ !normalized ||
246
+ normalized.length > 80 ||
247
+ /[\u0000-\u001f\u007f]/.test(normalized)
248
+ ) {
249
+ throw new Error("Workspace name must be 1-80 visible characters");
250
+ }
251
+ return this.enqueueWorkspaceMutation(async (draft) => {
252
+ const workspaces = await this.workspacePaths(undefined, draft);
253
+ if (!workspaces.has(canonical)) {
254
+ throw new Error("Workspace is not available");
255
+ }
256
+ draft.workspaceNames.set(canonical, normalized);
257
+ return normalized;
258
+ });
259
+ }
260
+
261
+ async renameSession(path: string, name: string) {
262
+ const session = await this.requireSession(path);
263
+ const normalized = name.trim();
264
+ if (
265
+ !normalized ||
266
+ normalized.length > 80 ||
267
+ /[\u0000-\u001f\u007f]/.test(normalized)
268
+ ) {
269
+ throw new Error("Conversation name must be 1-80 visible characters");
270
+ }
271
+ const manager =
272
+ session.id === this.runtime.sessionManager.getSessionId()
273
+ ? this.runtime.sessionManager
274
+ : SessionManager.open(session.path, this.runtime.sessionDirectory);
275
+ manager.appendSessionInfo(normalized);
276
+ return normalized;
277
+ }
278
+
279
+ async archiveSession(path: string) {
280
+ await this.ensureArchivesLoaded();
281
+ await this.enqueueArchiveMutation(async (draft) => {
282
+ const session = await this.requireSession(path);
283
+ draft.add(resolve(session.path));
284
+ });
285
+ }
286
+
287
+ async removeWorkspace(path: string) {
288
+ await this.ensureWorkspaceStateLoaded();
289
+ const canonical = resolve(path);
290
+ await this.enqueueWorkspaceMutation(async (draft) => {
291
+ const workspaces = await this.workspacePaths(undefined, draft);
292
+ if (!workspaces.has(canonical)) {
293
+ throw new Error("Workspace is not available");
294
+ }
295
+ const sessions = await SessionManager.listAll(this.runtime.sessionDirectory);
296
+ for (const session of sessions) {
297
+ if (resolve(session.cwd) === canonical) {
298
+ draft.ungroupedSessions.add(resolve(session.path));
299
+ }
300
+ }
301
+ if (resolve(this.runtime.cwd) === canonical) {
302
+ const currentPath =
303
+ this.runtime.sessionManager.getSessionFile() ??
304
+ `current:${this.runtime.sessionManager.getSessionId()}`;
305
+ draft.ungroupedSessions.add(resolve(currentPath));
306
+ }
307
+ draft.importedWorkspaces.delete(canonical);
308
+ draft.workspaceNames.delete(canonical);
309
+ draft.hiddenWorkspaces.add(canonical);
310
+ draft.restoreInitialWorkspace = false;
311
+ });
312
+ }
313
+
314
+ async listSessionProjection(pinnedPath?: string) {
315
+ await this.ensureWorkspaceStateLoaded();
316
+ await this.ensureArchivesLoaded();
317
+ const allSessions = await SessionManager.listAll(
318
+ this.runtime.sessionDirectory,
319
+ );
320
+ // Pi already returns this list newest-first. The scan remains Pi-owned;
321
+ // only the Web projection below is retained and bounded.
322
+ const sorted = allSessions;
323
+ const currentId = this.runtime.sessionManager.getSessionId();
324
+ const pinned = new Set(
325
+ sorted
326
+ .filter(
327
+ (session) =>
328
+ session.id === currentId ||
329
+ (pinnedPath !== undefined && session.path === pinnedPath),
330
+ )
331
+ .map((session) => session.path),
332
+ );
333
+ const retainedPaths = new Set(pinned);
334
+ for (const session of sorted) {
335
+ if (retainedPaths.size >= WEB_MAX_SESSIONS) break;
336
+ retainedPaths.add(session.path);
337
+ }
338
+ const projected = sorted
339
+ .filter((session) => retainedPaths.has(session.path))
340
+ .map((session) => ({
341
+ id: session.id,
342
+ path: session.path,
343
+ cwd: resolve(session.cwd),
344
+ ...(session.name
345
+ ? { name: boundedText(session.name, WEB_MAX_SESSION_PREVIEW) }
346
+ : {}),
347
+ modified: session.modified.toISOString(),
348
+ created: session.created.toISOString(),
349
+ messageCount: session.messageCount,
350
+ firstMessage: boundedText(
351
+ session.firstMessage,
352
+ WEB_MAX_SESSION_PREVIEW,
353
+ ),
354
+ ...(this.archivedSessions.has(resolve(session.path)) ? { archived: true } : {}),
355
+ ...(this.ungroupedSessions.has(resolve(session.path)) ? { ungrouped: true } : {}),
356
+ }));
357
+ if (
358
+ this.runtime.workspaceSelected === true &&
359
+ !projected.some((session) => session.id === currentId)
360
+ ) {
361
+ const entries = this.runtime.sessionManager.getBranch();
362
+ let firstUser = "";
363
+ let messageCount = 0;
364
+ for (const entry of entries) {
365
+ if (entry.type !== "message") continue;
366
+ messageCount++;
367
+ if (!firstUser) {
368
+ const projectedEntry = projectEntry(entry);
369
+ if (projectedEntry.message?.role === "user") {
370
+ firstUser = projectedEntry.message.content;
371
+ }
372
+ }
373
+ }
374
+ const now = new Date().toISOString();
375
+ const currentPath =
376
+ this.runtime.sessionManager.getSessionFile() ?? `current:${currentId}`;
377
+ projected.unshift({
378
+ id: currentId,
379
+ path: currentPath,
380
+ cwd: resolve(this.runtime.cwd),
381
+ ...(this.runtime.sessionManager.getSessionName()
382
+ ? {
383
+ name: boundedText(
384
+ this.runtime.sessionManager.getSessionName()!,
385
+ WEB_MAX_SESSION_PREVIEW,
386
+ ),
387
+ }
388
+ : {}),
389
+ modified: now,
390
+ created: now,
391
+ messageCount,
392
+ firstMessage: boundedText(firstUser, WEB_MAX_SESSION_PREVIEW),
393
+ ...(this.archivedSessions.has(resolve(currentPath))
394
+ ? { archived: true }
395
+ : {}),
396
+ ...(this.ungroupedSessions.has(resolve(currentPath))
397
+ ? { ungrouped: true }
398
+ : {}),
399
+ });
400
+ if (projected.length > WEB_MAX_SESSIONS) {
401
+ let removeAt = projected.length - 1;
402
+ while (
403
+ removeAt > 0 &&
404
+ pinnedPath !== undefined &&
405
+ projected[removeAt]?.path === pinnedPath
406
+ ) {
407
+ removeAt--;
408
+ }
409
+ projected.splice(removeAt, 1);
410
+ }
411
+ }
412
+ return {
413
+ sessions: projected,
414
+ omitted: Math.max(
415
+ 0,
416
+ allSessions.length +
417
+ (allSessions.some((session) => session.id === currentId) ||
418
+ this.runtime.workspaceSelected !== true
419
+ ? 0
420
+ : 1) -
421
+ projected.length,
422
+ ),
423
+ };
424
+ }
425
+
426
+ async listSessions(pinnedPath?: string): Promise<WebSessionSummary[]> {
427
+ return (await this.listSessionProjection(pinnedPath)).sessions;
428
+ }
429
+
430
+ async getSnapshot(selectedPath?: string) {
431
+ await this.ensureWorkspaceStateLoaded();
432
+ const sessionProjection = await this.listSessionProjection(selectedPath);
433
+ const sessions = sessionProjection.sessions;
434
+ const currentCwd = resolve(this.runtime.cwd);
435
+ const workspacePaths = await this.workspacePaths(sessions);
436
+ const retainedWorkspacePaths: string[] = [];
437
+ let visibleWorkspaceCount = 0;
438
+ for (const workspacePath of workspacePaths) {
439
+ if (this.hiddenWorkspaces.has(workspacePath)) continue;
440
+ visibleWorkspaceCount++;
441
+ if (retainedWorkspacePaths.length < WEB_MAX_WORKSPACES) {
442
+ retainedWorkspacePaths.push(workspacePath);
443
+ }
444
+ }
445
+ const workspaces: WebWorkspaceSummary[] = retainedWorkspacePaths
446
+ .map((path) => ({
447
+ path,
448
+ name: (this.workspaceNames.get(path) ?? basename(path)) || path,
449
+ current:
450
+ this.runtime.workspaceSelected === true && path === currentCwd,
451
+ }))
452
+ .sort((left, right) =>
453
+ left.current === right.current
454
+ ? left.name.localeCompare(right.name)
455
+ : left.current
456
+ ? -1
457
+ : 1,
458
+ );
459
+ const path =
460
+ selectedPath ??
461
+ sessions.find(
462
+ (session) => session.id === this.runtime.sessionManager.getSessionId(),
463
+ )?.path;
464
+ const selectedSession = path
465
+ ? await this.getSession(path, sessions)
466
+ : undefined;
467
+ const allModels = this.runtime.listModels();
468
+ const currentModel = allModels.find((model) => model.current);
469
+ const retainedModels = currentModel ? [currentModel] : [];
470
+ for (const model of allModels) {
471
+ if (retainedModels.length >= WEB_MAX_MODELS) break;
472
+ if (model !== currentModel) retainedModels.push(model);
473
+ }
474
+ const models = retainedModels.map((model) => ({
475
+ ...model,
476
+ provider: boundedText(model.provider, WEB_MAX_SESSION_PREVIEW),
477
+ id: boundedText(model.id, WEB_MAX_SESSION_PREVIEW),
478
+ name: boundedText(model.name, WEB_MAX_SESSION_PREVIEW),
479
+ label: boundedText(model.label, WEB_MAX_SESSION_PREVIEW),
480
+ }));
481
+ const snapshot = {
482
+ ...(this.runtime.workspaceSelected === true
483
+ ? { currentSessionId: this.runtime.sessionManager.getSessionId() }
484
+ : {}),
485
+ workspaces,
486
+ sessions,
487
+ models,
488
+ ...(selectedSession ? { selectedSession } : {}),
489
+ runtime: {
490
+ status: this.runtime.isIdle()
491
+ ? ("idle" as const)
492
+ : ("running" as const),
493
+ capabilities: webCapabilitySnapshot(this.runtime.sessionManager),
494
+ },
495
+ truncation: {
496
+ truncated:
497
+ sessionProjection.omitted > 0 ||
498
+ visibleWorkspaceCount > workspaces.length ||
499
+ allModels.length > models.length ||
500
+ selectedSession?.truncation.truncated === true,
501
+ sessionsOmitted: sessionProjection.omitted,
502
+ workspacesOmitted: visibleWorkspaceCount - workspaces.length,
503
+ modelsOmitted: allModels.length - models.length,
504
+ maxBytes: WEB_MAX_SNAPSHOT_BYTES,
505
+ bytes: 0,
506
+ } satisfies WebSnapshotTruncation,
507
+ };
508
+ this.fitSnapshot(snapshot);
509
+ snapshot.truncation.bytes = jsonByteLength(snapshot);
510
+ return snapshot;
511
+ }
512
+
513
+ private fitSnapshot(snapshot: {
514
+ currentSessionId?: string;
515
+ workspaces: WebWorkspaceSummary[];
516
+ sessions: WebSessionSummary[];
517
+ selectedSession?: WebSessionProjection;
518
+ models: ReturnType<WebRuntimeController["listModels"]>;
519
+ truncation: WebSnapshotTruncation;
520
+ }) {
521
+ const targetBytes = WEB_MAX_SNAPSHOT_BYTES - 1_024;
522
+ let bytes = jsonByteLength(snapshot);
523
+ if (bytes <= targetBytes) return;
524
+
525
+ const selected = snapshot.selectedSession;
526
+ if (selected && selected.entries.length > 0) {
527
+ let removed = 0;
528
+ while (selected.entries.length > 0 && bytes > targetBytes) {
529
+ const entry = selected.entries.shift();
530
+ if (!entry) break;
531
+ removed++;
532
+ bytes -= jsonByteLength(entry) + 1;
533
+ }
534
+ let messagePartsOmitted = 0;
535
+ let messagesTruncated = 0;
536
+ for (const entry of selected.entries) {
537
+ if (entry.type !== "message" || !entry.message?.truncation) continue;
538
+ messagesTruncated++;
539
+ messagePartsOmitted += entry.message.truncation.partsOmitted ?? 0;
540
+ }
541
+ selected.bytes = jsonByteLength(selected.entries);
542
+ selected.truncation = {
543
+ ...selected.truncation,
544
+ truncated: true,
545
+ entriesOmitted: selected.truncation.entriesOmitted + removed,
546
+ messagePartsOmitted,
547
+ messagesTruncated,
548
+ };
549
+ }
550
+
551
+ const selectedPath = selected?.path;
552
+ bytes = jsonByteLength(snapshot);
553
+ while (bytes > targetBytes && snapshot.sessions.length > 1) {
554
+ let index = -1;
555
+ for (let candidate = snapshot.sessions.length - 1; candidate >= 0; candidate--) {
556
+ const session = snapshot.sessions[candidate]!;
557
+ if (
558
+ session.id !== snapshot.currentSessionId &&
559
+ session.path !== selectedPath
560
+ ) {
561
+ index = candidate;
562
+ break;
563
+ }
564
+ }
565
+ if (index < 0) break;
566
+ const [removed] = snapshot.sessions.splice(index, 1);
567
+ if (removed) bytes -= jsonByteLength(removed) + 1;
568
+ snapshot.truncation.sessionsOmitted++;
569
+ }
570
+ bytes = jsonByteLength(snapshot);
571
+ while (bytes > targetBytes && snapshot.workspaces.length > 1) {
572
+ let index = -1;
573
+ for (let candidate = snapshot.workspaces.length - 1; candidate >= 0; candidate--) {
574
+ if (!snapshot.workspaces[candidate]!.current) {
575
+ index = candidate;
576
+ break;
577
+ }
578
+ }
579
+ if (index < 0) break;
580
+ const [removed] = snapshot.workspaces.splice(index, 1);
581
+ if (removed) bytes -= jsonByteLength(removed) + 1;
582
+ snapshot.truncation.workspacesOmitted++;
583
+ }
584
+ bytes = jsonByteLength(snapshot);
585
+ while (bytes > targetBytes && snapshot.models.length > 1) {
586
+ let index = -1;
587
+ for (let candidate = snapshot.models.length - 1; candidate >= 0; candidate--) {
588
+ if (!snapshot.models[candidate]!.current) {
589
+ index = candidate;
590
+ break;
591
+ }
592
+ }
593
+ if (index < 0) break;
594
+ const [removed] = snapshot.models.splice(index, 1);
595
+ if (removed) bytes -= jsonByteLength(removed) + 1;
596
+ snapshot.truncation.modelsOmitted++;
597
+ }
598
+ snapshot.truncation.truncated = true;
599
+ }
600
+
601
+ private captureWorkspaceState(): WorkspaceStateSnapshot {
602
+ return {
603
+ importedWorkspaces: new Set(this.importedWorkspaces),
604
+ hiddenWorkspaces: new Set(this.hiddenWorkspaces),
605
+ workspaceNames: new Map(this.workspaceNames),
606
+ ungroupedSessions: new Set(this.ungroupedSessions),
607
+ restoreInitialWorkspace: this.restoreInitialWorkspace,
608
+ };
609
+ }
610
+
611
+ private restoreWorkspaceState(state: WorkspaceStateSnapshot) {
612
+ this.importedWorkspaces.clear();
613
+ for (const path of state.importedWorkspaces) this.importedWorkspaces.add(path);
614
+ this.hiddenWorkspaces.clear();
615
+ for (const path of state.hiddenWorkspaces) this.hiddenWorkspaces.add(path);
616
+ this.workspaceNames.clear();
617
+ for (const [path, name] of state.workspaceNames) {
618
+ this.workspaceNames.set(path, name);
619
+ }
620
+ this.ungroupedSessions.clear();
621
+ for (const path of state.ungroupedSessions) {
622
+ this.ungroupedSessions.add(path);
623
+ }
624
+ this.restoreInitialWorkspace = state.restoreInitialWorkspace;
625
+ }
626
+
627
+ private async workspacePaths(
628
+ knownSessions?: WebSessionSummary[],
629
+ workspaceState?: WorkspaceStateSnapshot,
630
+ ) {
631
+ const sessions = knownSessions ?? (await this.listSessions());
632
+ return new Set([
633
+ ...(this.runtime.workspaceSelected === true
634
+ ? [resolve(this.runtime.cwd)]
635
+ : []),
636
+ ...(workspaceState?.importedWorkspaces ?? this.importedWorkspaces),
637
+ ...sessions.map((session) => session.cwd),
638
+ ]);
639
+ }
640
+
641
+ async getSession(
642
+ path: string,
643
+ knownSessions?: WebSessionSummary[],
644
+ ): Promise<WebSessionProjection | undefined> {
645
+ const sessions = knownSessions ?? (await this.listSessions(path));
646
+ const summary = sessions.find((session) => session.path === path);
647
+ if (!summary) return undefined;
648
+
649
+ const manager =
650
+ summary.id === this.runtime.sessionManager.getSessionId()
651
+ ? this.runtime.sessionManager
652
+ : SessionManager.open(path);
653
+ const projected = projectEntries(manager.getBranch());
654
+ return {
655
+ id: summary.id,
656
+ path: summary.path,
657
+ cwd: summary.cwd,
658
+ ...projected,
659
+ };
660
+ }
661
+ }
@@ -0,0 +1,20 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ export async function openBrowser(url: string) {
7
+ const command =
8
+ process.platform === "darwin"
9
+ ? "open"
10
+ : process.platform === "win32"
11
+ ? "cmd"
12
+ : "xdg-open";
13
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
14
+ try {
15
+ await execFileAsync(command, args);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
@@ -0,0 +1,4 @@
1
+ export const MARKED_BROWSER_URL = new URL(
2
+ "./lib/marked.umd.js",
3
+ import.meta.resolve("marked/package.json"),
4
+ );