@uniqbit/mate-core 0.17.0-canary.9 → 0.17.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 (103) hide show
  1. package/package.json +11 -5
  2. package/src/cli/commands/artifact/finish/index.ts +0 -9
  3. package/src/cli/commands/artifact/finish/openspec.ts +2 -2
  4. package/src/cli/commands/artifact/finish/registry.ts +1 -1
  5. package/src/cli/commands/artifact/pending/discovery.ts +1 -1
  6. package/src/cli/commands/artifact/pending/index.ts +0 -16
  7. package/src/cli/commands/cap/graphify.ts +1 -1
  8. package/src/cli/commands/cap/index-cmd.ts +1 -1
  9. package/src/cli/commands/cap/tokensave.ts +4 -3
  10. package/src/cli/commands/companion/companion.ts +4 -0
  11. package/src/cli/commands/companion/hub.ts +2 -1
  12. package/src/cli/commands/companion/list.ts +2 -1
  13. package/src/cli/commands/companion/prepare.ts +2 -2
  14. package/src/cli/commands/companion/update.ts +334 -0
  15. package/src/cli/commands/doctor.ts +2 -1
  16. package/src/cli/commands/install.ts +59 -5
  17. package/src/cli/commands/launch/shared.ts +2 -1
  18. package/src/cli/commands/plugin/plugin.ts +4 -0
  19. package/src/cli/commands/plugin/verify.ts +25 -0
  20. package/src/cli/commands/report/highlight.ts +1 -4
  21. package/src/cli/commands/report/renderer.ts +0 -2
  22. package/src/cli/commands/studio/access.ts +201 -0
  23. package/src/cli/commands/studio/index.ts +207 -26
  24. package/src/cli/commands/studio/openspec-cli.ts +1 -1
  25. package/src/cli/commands/studio/selection.ts +127 -11
  26. package/src/cli/commands/studio/server.ts +355 -64
  27. package/src/cli/commands/studio/terminal-assets.ts +50 -0
  28. package/src/cli/commands/studio/terminal-launch.ts +71 -0
  29. package/src/cli/commands/studio/terminal.ts +593 -0
  30. package/src/cli/commands/studio/vault.ts +320 -75
  31. package/src/cli/commands/studio/views/client.ts +90 -9
  32. package/src/cli/commands/studio/views/companion-picker.tsx +9 -5
  33. package/src/cli/commands/studio/views/companion-selector.tsx +11 -7
  34. package/src/cli/commands/studio/views/document.tsx +411 -107
  35. package/src/cli/commands/studio/views/model.ts +14 -1
  36. package/src/cli/commands/studio/views/selection-fields.tsx +14 -0
  37. package/src/cli/commands/studio/views/styles.ts +106 -13
  38. package/src/cli/commands/studio/views/terminal.tsx +426 -0
  39. package/src/cli/commands/studio/views/vault/state.ts +42 -0
  40. package/src/cli/commands/studio/views/workflow/steps.ts +10 -3
  41. package/src/cli/commands/update.ts +11 -8
  42. package/src/cli/companion-link-wizard.tsx +1 -1
  43. package/src/cli/companion-selector.tsx +1 -1
  44. package/src/cli/launch-selector.tsx +1 -1
  45. package/src/cli/main.ts +2 -1
  46. package/src/cli/usage.ts +7 -3
  47. package/src/cli/write-json-stdout.ts +2 -2
  48. package/src/lib/components/spinner.tsx +2 -2
  49. package/src/lib/components/startup-progress.tsx +1 -1
  50. package/src/lib/context-mode-package.ts +1 -1
  51. package/src/lib/opencode-plugin-package.ts +3 -0
  52. package/src/lib/orchestrator/adapters/opencode.ts +53 -13
  53. package/src/lib/orchestrator/companion-hub.ts +3 -3
  54. package/src/lib/orchestrator/companion-registry-store.ts +1 -1
  55. package/src/lib/orchestrator/config-store.ts +2 -2
  56. package/src/lib/orchestrator/editor.ts +4 -12
  57. package/src/lib/orchestrator/engine-guard.ts +9 -1
  58. package/src/lib/orchestrator/projection-entries.ts +1 -1
  59. package/src/lib/orchestrator/projection-record.ts +1 -1
  60. package/src/lib/orchestrator/projection-types.ts +0 -4
  61. package/src/lib/orchestrator/repo-local-registry.ts +0 -1
  62. package/src/lib/orchestrator/root-context.ts +1 -1
  63. package/src/lib/orchestrator/setup-compatibilities.ts +1 -1
  64. package/src/lib/orchestrator/setup-preflight.ts +1 -1
  65. package/src/lib/orchestrator/types.ts +1 -1
  66. package/src/lib/orchestrator/working-repo-projection.ts +1 -1
  67. package/src/lib/orchestrator/workspace-inventory.ts +2 -2
  68. package/src/lib/package-paths.ts +1 -11
  69. package/src/lib/package-root.ts +43 -0
  70. package/src/lib/prebuilt-workspace.ts +1 -1
  71. package/src/lib/public-npm.ts +1 -1
  72. package/src/opencode/companion-hooks.ts +131 -153
  73. package/src/opencode/index.ts +12 -6
  74. package/src/opencode/tui.tsx +46 -49
  75. package/src/playbooks/companion-guidance.ts +1 -28
  76. package/src/runtime/companion-sync.ts +0 -3
  77. package/src/templates/capabilities/openspec-cap/mate-minimal/schema.yaml +2 -1
  78. package/src/templates/providers/opencode/opencode.json +6 -4
  79. package/src/tools/setup/__snapshots__/runtime-surface-golden.test.ts.snap +500 -408
  80. package/src/tools/setup/capabilities/context-mode.ts +45 -25
  81. package/src/tools/setup/capabilities/graphify-shared.ts +3 -3
  82. package/src/tools/setup/capabilities/graphify.ts +2 -2
  83. package/src/tools/setup/capabilities/openspec.ts +2 -7
  84. package/src/tools/setup/capabilities/react-doctor.ts +1 -1
  85. package/src/tools/setup/capabilities/rtk.ts +2 -2
  86. package/src/tools/setup/capabilities/tokensave.ts +98 -12
  87. package/src/tools/setup/context-services.ts +3 -6
  88. package/src/tools/setup/dynamic-plugins/declarations.ts +47 -0
  89. package/src/tools/setup/dynamic-plugins/frozen.ts +215 -0
  90. package/src/tools/setup/dynamic-plugins/hydrate.ts +1 -40
  91. package/src/tools/setup/dynamic-plugins/loader.ts +16 -0
  92. package/src/tools/setup/dynamic-plugins/policy.ts +55 -0
  93. package/src/tools/setup/dynamic-plugins/staging.ts +53 -0
  94. package/src/tools/setup/dynamic-plugins/verify.ts +70 -0
  95. package/src/tools/setup/mate.ts +1 -1
  96. package/src/tools/setup/providers/claude-format.ts +1 -1
  97. package/src/tools/setup/providers/claude.ts +1 -1
  98. package/src/tools/setup/providers/opencode-format.ts +220 -25
  99. package/src/tools/setup/providers/opencode.ts +63 -21
  100. package/src/tools/setup/runtime-documents.ts +9 -11
  101. package/src/tools/setup.ts +0 -4
  102. package/src/cli/commands/studio/views/pairings.tsx +0 -38
  103. package/src/templates/providers/opencode/tui.json +0 -3
@@ -0,0 +1,71 @@
1
+ import path from "node:path";
2
+
3
+ import { FRAMEWORK_NAME } from "../../../framework";
4
+ import { ConfigStore, mergeWithDefaults } from "../../../lib/orchestrator/config-store";
5
+ import type { StudioInventory, StudioInventoryCompanion } from "./inventory";
6
+ import { resolveCompanion } from "./selection";
7
+ import {
8
+ agentInstalled,
9
+ TERMINAL_AGENTS,
10
+ type TerminalAgent,
11
+ type TerminalLaunchResolution,
12
+ } from "./terminal";
13
+
14
+ /** Agents the companion allows and this installation can start. */
15
+ export async function launchableAgents(
16
+ companionPath: string,
17
+ installed: (agent: TerminalAgent) => boolean = agentInstalled,
18
+ ): Promise<TerminalAgent[]> {
19
+ const configPath = path.join(companionPath, `.${FRAMEWORK_NAME}`, "config", "framework.yaml");
20
+ let allowed: string[];
21
+ try {
22
+ allowed = mergeWithDefaults(await new ConfigStore(configPath).load()).allowedAgents;
23
+ } catch {
24
+ return [];
25
+ }
26
+ return TERMINAL_AGENTS.filter((agent) => allowed.includes(agent) && installed(agent));
27
+ }
28
+
29
+ export interface LaunchResolverOptions {
30
+ collectInventory: () => Promise<StudioInventory>;
31
+ /** Pinned by `serve --companion`; overrides the page selection. */
32
+ launchCompanion?: string | null;
33
+ launchableAgents?: (companionPath: string) => Promise<TerminalAgent[]>;
34
+ }
35
+
36
+ /** The companion a launch targets: the pinned one, else the page's, both read from the current inventory. */
37
+ export async function effectiveLaunchCompanion(
38
+ inventory: StudioInventory,
39
+ launchCompanion: string | null | undefined,
40
+ digest: string | null,
41
+ ): Promise<StudioInventoryCompanion | null> {
42
+ if (launchCompanion) {
43
+ return inventory.companions.find((companion) => companion.path === launchCompanion) ?? null;
44
+ }
45
+ return resolveCompanion(inventory, digest);
46
+ }
47
+
48
+ /** Revalidates at launch time; the rendered page and its digest are never trusted. */
49
+ export function createLaunchResolver(
50
+ options: LaunchResolverOptions,
51
+ ): (agent: string, digest: string | null) => Promise<TerminalLaunchResolution> {
52
+ const agents = options.launchableAgents ?? launchableAgents;
53
+ return async (agent, digest) => {
54
+ const companion = await effectiveLaunchCompanion(
55
+ await options.collectInventory(),
56
+ options.launchCompanion,
57
+ digest,
58
+ );
59
+ if (!companion) {
60
+ return {
61
+ reason: options.launchCompanion
62
+ ? "the launch companion is no longer registered"
63
+ : "no registered companion is selected",
64
+ };
65
+ }
66
+ if (!(await agents(companion.path)).includes(agent as TerminalAgent)) {
67
+ return { reason: `${agent} is not allowed by this companion or is not installed` };
68
+ }
69
+ return { companionPath: companion.path };
70
+ };
71
+ }
@@ -0,0 +1,593 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ export const TERMINAL_AGENTS = ["claude", "opencode"] as const;
4
+ export type TerminalAgent = (typeof TERMINAL_AGENTS)[number];
5
+
6
+ export const TERMINAL_LIMITS = {
7
+ ringBytes: 1024 * 1024,
8
+ viewerQueueBytes: 1024 * 1024,
9
+ inputBytes: 64 * 1024,
10
+ cols: [2, 500] as const,
11
+ rows: [1, 300] as const,
12
+ sessions: 4,
13
+ pingMs: 15_000,
14
+ heartbeatDeadlineMs: 30_000,
15
+ termGraceMs: 5_000,
16
+ reapMs: 5_000,
17
+ };
18
+
19
+ export const DEFAULT_DETACH_MINUTES = { serve: 30, interactive: 2 } as const;
20
+
21
+ /**
22
+ * Bun's PTY, reached through `globalThis` because the repository typechecks
23
+ * against Node types only.
24
+ */
25
+ interface BunTerminalRuntime {
26
+ Terminal?: unknown;
27
+ spawn(
28
+ argv: string[],
29
+ options: {
30
+ cwd: string;
31
+ env: Record<string, string>;
32
+ terminal: {
33
+ cols: number;
34
+ rows: number;
35
+ name: string;
36
+ data: (terminal: unknown, data: Uint8Array) => void;
37
+ };
38
+ },
39
+ ): {
40
+ pid: number;
41
+ exited: Promise<number>;
42
+ terminal?: {
43
+ write(data: string | Uint8Array): number;
44
+ resize(c: number, r: number): void;
45
+ close(): void;
46
+ };
47
+ };
48
+ which(command: string): string | null;
49
+ }
50
+
51
+ function bunRuntime(): BunTerminalRuntime | undefined {
52
+ return (globalThis as unknown as { Bun?: BunTerminalRuntime }).Bun;
53
+ }
54
+
55
+ /** `null` when this runtime can host the terminal; otherwise the requirement it misses. */
56
+ export function terminalUnsupportedReason(
57
+ platform: NodeJS.Platform = process.platform,
58
+ runtime: BunTerminalRuntime | undefined = bunRuntime(),
59
+ ): string | null {
60
+ if (platform === "win32") return "requires macOS or Linux; Windows has no supported terminal";
61
+ if (!runtime || typeof runtime.Terminal !== "function") {
62
+ return "requires Bun 1.3.5 or later, which provides a native pseudo-terminal";
63
+ }
64
+ return null;
65
+ }
66
+
67
+ export function agentInstalled(agent: TerminalAgent): boolean {
68
+ return Boolean(bunRuntime()?.which(agent));
69
+ }
70
+
71
+ export interface TerminalProcess {
72
+ pid: number;
73
+ exited: Promise<number>;
74
+ write(data: string): void;
75
+ resize(cols: number, rows: number): void;
76
+ close(): void;
77
+ }
78
+
79
+ export interface TerminalSpawnRequest {
80
+ argv: string[];
81
+ cwd: string;
82
+ env: Record<string, string>;
83
+ cols: number;
84
+ rows: number;
85
+ onData: (data: Uint8Array) => void;
86
+ }
87
+
88
+ export type TerminalSpawn = (request: TerminalSpawnRequest) => TerminalProcess;
89
+
90
+ /** A PTY child is its own session and process-group leader, so `-pid` reaches its descendants. */
91
+ export const bunTerminalSpawn: TerminalSpawn = (request) => {
92
+ const runtime = bunRuntime();
93
+ if (!runtime) throw new Error("the terminal requires the Bun runtime");
94
+ const child = runtime.spawn(request.argv, {
95
+ cwd: request.cwd,
96
+ env: request.env,
97
+ terminal: {
98
+ cols: request.cols,
99
+ rows: request.rows,
100
+ name: "xterm-256color",
101
+ data: (_terminal, data) => request.onData(data),
102
+ },
103
+ });
104
+ return {
105
+ pid: child.pid,
106
+ exited: child.exited,
107
+ write: (data) => void child.terminal?.write(data),
108
+ resize: (cols, rows) => child.terminal?.resize(cols, rows),
109
+ close: () => child.terminal?.close(),
110
+ };
111
+ };
112
+
113
+ function signalGroup(pid: number, signal: NodeJS.Signals): void {
114
+ try {
115
+ process.kill(-pid, signal);
116
+ } catch {
117
+ /** The group is already gone. */
118
+ }
119
+ }
120
+
121
+ /** Keeps the newest `limit` bytes; the oldest are dropped, possibly mid-sequence. */
122
+ export class RingBuffer {
123
+ private chunks: Uint8Array[] = [];
124
+ private size = 0;
125
+
126
+ constructor(private readonly limit: number) {}
127
+
128
+ push(data: Uint8Array): void {
129
+ if (data.byteLength >= this.limit) {
130
+ this.chunks = [data.slice(data.byteLength - this.limit)];
131
+ this.size = this.limit;
132
+ return;
133
+ }
134
+ this.chunks.push(data);
135
+ this.size += data.byteLength;
136
+ while (this.size > this.limit) {
137
+ const first = this.chunks[0]!;
138
+ const excess = this.size - this.limit;
139
+ if (first.byteLength <= excess) {
140
+ this.chunks.shift();
141
+ this.size -= first.byteLength;
142
+ } else {
143
+ this.chunks[0] = first.slice(excess);
144
+ this.size -= excess;
145
+ }
146
+ }
147
+ }
148
+
149
+ get byteLength(): number {
150
+ return this.size;
151
+ }
152
+
153
+ snapshot(): Uint8Array {
154
+ const out = new Uint8Array(this.size);
155
+ let offset = 0;
156
+ for (const chunk of this.chunks) {
157
+ out.set(chunk, offset);
158
+ offset += chunk.byteLength;
159
+ }
160
+ return out;
161
+ }
162
+ }
163
+
164
+ /** One browser connection. `bufferedAmount` is what the socket has queued but not yet sent. */
165
+ export interface TerminalViewer {
166
+ send(data: string | Uint8Array): void;
167
+ bufferedAmount(): number;
168
+ ping(): void;
169
+ close(code?: number, reason?: string): void;
170
+ }
171
+
172
+ export interface TerminalSessionInfo {
173
+ id: string;
174
+ agent: TerminalAgent;
175
+ companionPath: string;
176
+ attached: boolean;
177
+ startedAt: number;
178
+ }
179
+
180
+ export type TerminalLaunchResolution = { companionPath: string } | { reason: string };
181
+
182
+ export interface TerminalRegistryOptions {
183
+ detachMs: number;
184
+ /** Validates the agent against the effective companion at launch time. */
185
+ resolveLaunch: (
186
+ agent: string,
187
+ companionDigest: string | null,
188
+ ) => Promise<TerminalLaunchResolution>;
189
+ /** The Mate invocation prefix, e.g. `[bun, cli.mjs]`. */
190
+ mateCommand: string[];
191
+ noGit?: boolean;
192
+ env?: NodeJS.ProcessEnv;
193
+ spawn?: TerminalSpawn;
194
+ signalGroup?: (pid: number, signal: NodeJS.Signals) => void;
195
+ now?: () => number;
196
+ limits?: Partial<typeof TERMINAL_LIMITS>;
197
+ }
198
+
199
+ interface Session {
200
+ id: string;
201
+ agent: TerminalAgent;
202
+ companionPath: string;
203
+ startedAt: number;
204
+ process: TerminalProcess;
205
+ ring: RingBuffer;
206
+ viewer: TerminalViewer | null;
207
+ detachedAt: number | null;
208
+ expiry: ReturnType<typeof setTimeout> | null;
209
+ ending: Promise<void> | null;
210
+ exited: boolean;
211
+ }
212
+
213
+ interface Connection {
214
+ viewer: TerminalViewer;
215
+ lastPong: number;
216
+ session: Session | null;
217
+ }
218
+
219
+ export interface TerminalConnection {
220
+ message(raw: string | Uint8Array): Promise<void>;
221
+ pong(): void;
222
+ closed(): void;
223
+ }
224
+
225
+ function send(viewer: TerminalViewer, message: Record<string, unknown>): void {
226
+ try {
227
+ viewer.send(JSON.stringify(message));
228
+ } catch {
229
+ /** A closing socket; its close handler detaches it. */
230
+ }
231
+ }
232
+
233
+ function inRange(value: unknown, [min, max]: readonly [number, number]): value is number {
234
+ return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
235
+ }
236
+
237
+ /**
238
+ * Every agent session one Studio server started. Sessions detach rather than
239
+ * end when their viewer goes away, and all of them end with `stopAll`.
240
+ */
241
+ export class TerminalRegistry {
242
+ private readonly sessions = new Map<string, Session>();
243
+ private readonly connections = new Set<Connection>();
244
+ private readonly limits: typeof TERMINAL_LIMITS;
245
+ private readonly spawn: TerminalSpawn;
246
+ private readonly signal: (pid: number, signal: NodeJS.Signals) => void;
247
+ private readonly now: () => number;
248
+ private heartbeat: ReturnType<typeof setInterval> | null = null;
249
+ private stopping = false;
250
+
251
+ constructor(private readonly options: TerminalRegistryOptions) {
252
+ this.limits = { ...TERMINAL_LIMITS, ...options.limits };
253
+ this.spawn = options.spawn ?? bunTerminalSpawn;
254
+ this.signal = options.signalGroup ?? signalGroup;
255
+ this.now = options.now ?? Date.now;
256
+ }
257
+
258
+ list(): TerminalSessionInfo[] {
259
+ return [...this.sessions.values()].map((session) => ({
260
+ id: session.id,
261
+ agent: session.agent,
262
+ companionPath: session.companionPath,
263
+ attached: session.viewer !== null,
264
+ startedAt: session.startedAt,
265
+ }));
266
+ }
267
+
268
+ connect(viewer: TerminalViewer): TerminalConnection {
269
+ const connection: Connection = { viewer, lastPong: this.now(), session: null };
270
+ this.connections.add(connection);
271
+ this.ensureHeartbeat();
272
+ return {
273
+ message: (raw) => this.message(connection, raw),
274
+ pong: () => {
275
+ connection.lastPong = this.now();
276
+ },
277
+ closed: () => {
278
+ this.connections.delete(connection);
279
+ this.detach(connection, "closed", false);
280
+ },
281
+ };
282
+ }
283
+
284
+ /** Runs one heartbeat round; exposed so tests need not wait on real timers. */
285
+ beat(): void {
286
+ const now = this.now();
287
+ for (const connection of this.connections) {
288
+ if (now - connection.lastPong > this.limits.heartbeatDeadlineMs) {
289
+ this.connections.delete(connection);
290
+ this.detach(connection, "heartbeat", true);
291
+ continue;
292
+ }
293
+ try {
294
+ connection.viewer.ping();
295
+ } catch {
296
+ /** Closed; its close handler runs. */
297
+ }
298
+ }
299
+ }
300
+
301
+ async end(id: string): Promise<boolean> {
302
+ const session = this.sessions.get(id);
303
+ if (!session) return false;
304
+ await this.finish(session, { type: "ended" });
305
+ return true;
306
+ }
307
+
308
+ async stopAll(): Promise<void> {
309
+ this.stopping = true;
310
+ if (this.heartbeat) clearInterval(this.heartbeat);
311
+ this.heartbeat = null;
312
+ await Promise.all(
313
+ [...this.sessions.values()].map((session) =>
314
+ this.finish(session, { type: "ended", reason: "Studio stopped" }),
315
+ ),
316
+ );
317
+ }
318
+
319
+ private ensureHeartbeat(): void {
320
+ if (this.heartbeat || this.stopping) return;
321
+ this.heartbeat = setInterval(() => this.beat(), this.limits.pingMs);
322
+ (this.heartbeat as { unref?: () => void }).unref?.();
323
+ }
324
+
325
+ private async message(connection: Connection, raw: string | Uint8Array): Promise<void> {
326
+ const text = typeof raw === "string" ? raw : new TextDecoder().decode(raw);
327
+ if (text.length > this.limits.inputBytes * 2) {
328
+ send(connection.viewer, { type: "error", reason: "message too large" });
329
+ return;
330
+ }
331
+ let parsed: Record<string, unknown>;
332
+ try {
333
+ parsed = JSON.parse(text) as Record<string, unknown>;
334
+ } catch {
335
+ send(connection.viewer, { type: "error", reason: "malformed message" });
336
+ return;
337
+ }
338
+ switch (parsed.type) {
339
+ case "start":
340
+ return this.start(connection, parsed);
341
+ case "attach":
342
+ return this.attach(connection, parsed);
343
+ case "input":
344
+ return this.input(connection, parsed);
345
+ case "resize":
346
+ return this.resize(connection, parsed);
347
+ case "end":
348
+ if (connection.session) await this.finish(connection.session, { type: "ended" });
349
+ return;
350
+ default:
351
+ send(connection.viewer, { type: "error", reason: "unknown message" });
352
+ }
353
+ }
354
+
355
+ private dimensions(message: Record<string, unknown>): { cols: number; rows: number } | null {
356
+ return inRange(message.cols, this.limits.cols) && inRange(message.rows, this.limits.rows)
357
+ ? { cols: message.cols, rows: message.rows }
358
+ : null;
359
+ }
360
+
361
+ private async start(connection: Connection, message: Record<string, unknown>): Promise<void> {
362
+ const viewer = connection.viewer;
363
+ if (this.stopping) return send(viewer, { type: "error", reason: "Studio is stopping" });
364
+ if (connection.session) {
365
+ return send(viewer, { type: "error", reason: "this connection already views a session" });
366
+ }
367
+ const size = this.dimensions(message);
368
+ if (!size) return send(viewer, { type: "error", reason: "terminal size out of range" });
369
+ if (!TERMINAL_AGENTS.includes(message.agent as TerminalAgent)) {
370
+ return send(viewer, { type: "error", reason: "the terminal starts only claude or opencode" });
371
+ }
372
+ const agent = message.agent as TerminalAgent;
373
+ const digest = typeof message.companion === "string" ? message.companion : null;
374
+ const resolved = await this.options.resolveLaunch(agent, digest);
375
+ if ("reason" in resolved) return send(viewer, { type: "error", reason: resolved.reason });
376
+ if (connection.session || !this.connections.has(connection)) return;
377
+
378
+ if (this.sessions.size >= this.limits.sessions) {
379
+ const detached = [...this.sessions.values()]
380
+ .filter((session) => session.viewer === null && session.ending === null)
381
+ .toSorted((a, b) => a.detachedAt! - b.detachedAt!)[0];
382
+ if (!detached) {
383
+ return send(viewer, {
384
+ type: "error",
385
+ reason: `Studio already runs ${this.limits.sessions} attached sessions; end one first`,
386
+ });
387
+ }
388
+ void this.finish(detached, { type: "ended", reason: "evicted" });
389
+ }
390
+
391
+ const session = this.launch(agent, resolved.companionPath, size);
392
+ this.attachTo(connection, session, size);
393
+ }
394
+
395
+ private launch(
396
+ agent: TerminalAgent,
397
+ companionPath: string,
398
+ size: { cols: number; rows: number },
399
+ ): Session {
400
+ const env: Record<string, string> = {};
401
+ for (const [key, value] of Object.entries(this.options.env ?? process.env)) {
402
+ if (value !== undefined) env[key] = value;
403
+ }
404
+ delete env.MATE_REPO_PATH;
405
+ delete env.MATE_REPO_ID;
406
+ delete env.MATE_STUDIO_TOKEN;
407
+ env.MATE_ARTIFACT_PATH = companionPath;
408
+ env.TERM = "xterm-256color";
409
+ const argv = [
410
+ ...this.options.mateCommand,
411
+ agent,
412
+ "--",
413
+ "--companion",
414
+ "--yes",
415
+ ...(this.options.noGit ? ["--no-git"] : []),
416
+ ];
417
+
418
+ const session = {
419
+ id: randomUUID(),
420
+ agent,
421
+ companionPath,
422
+ startedAt: this.now(),
423
+ ring: new RingBuffer(this.limits.ringBytes),
424
+ viewer: null,
425
+ detachedAt: null,
426
+ expiry: null,
427
+ ending: null,
428
+ exited: false,
429
+ } as Omit<Session, "process"> as Session;
430
+ session.process = this.spawn({
431
+ argv,
432
+ cwd: companionPath,
433
+ env,
434
+ cols: size.cols,
435
+ rows: size.rows,
436
+ onData: (data) => this.output(session, data),
437
+ });
438
+ this.sessions.set(session.id, session);
439
+ void session.process.exited.then((status) => {
440
+ session.exited = true;
441
+ if (!session.ending) void this.finish(session, { type: "exit", status });
442
+ });
443
+ return session;
444
+ }
445
+
446
+ private output(session: Session, data: Uint8Array): void {
447
+ session.ring.push(data);
448
+ const viewer = session.viewer;
449
+ if (!viewer) return;
450
+ try {
451
+ viewer.send(data);
452
+ } catch {
453
+ return;
454
+ }
455
+ if (viewer.bufferedAmount() > this.limits.viewerQueueBytes) {
456
+ const connection = [...this.connections].find((candidate) => candidate.viewer === viewer);
457
+ if (connection) this.detach(connection, "slow", true);
458
+ }
459
+ }
460
+
461
+ private attachTo(
462
+ connection: Connection,
463
+ session: Session,
464
+ size: { cols: number; rows: number },
465
+ ): void {
466
+ if (session.viewer && session.viewer !== connection.viewer) {
467
+ const previous = [...this.connections].find((c) => c.viewer === session.viewer);
468
+ send(session.viewer, { type: "takenOver" });
469
+ if (previous) previous.session = null;
470
+ try {
471
+ session.viewer.close(4001, "taken over");
472
+ } catch {
473
+ /** Already closing. */
474
+ }
475
+ }
476
+ if (session.expiry) clearTimeout(session.expiry);
477
+ session.expiry = null;
478
+ session.detachedAt = null;
479
+ session.viewer = connection.viewer;
480
+ connection.session = session;
481
+
482
+ send(connection.viewer, {
483
+ type: "ready",
484
+ sessionId: session.id,
485
+ agent: session.agent,
486
+ companionPath: session.companionPath,
487
+ });
488
+ const replay = session.ring.snapshot();
489
+ if (replay.byteLength > 0) {
490
+ try {
491
+ connection.viewer.send(replay);
492
+ } catch {
493
+ /** Closed during replay. */
494
+ }
495
+ }
496
+ /** Replay can start mid-sequence; a size change makes the agent's interface redraw. */
497
+ session.process.resize(size.cols, size.rows > 1 ? size.rows - 1 : size.rows + 1);
498
+ session.process.resize(size.cols, size.rows);
499
+ }
500
+
501
+ private async attach(connection: Connection, message: Record<string, unknown>): Promise<void> {
502
+ const size = this.dimensions(message);
503
+ if (!size)
504
+ return send(connection.viewer, { type: "error", reason: "terminal size out of range" });
505
+ const session =
506
+ typeof message.sessionId === "string" ? this.sessions.get(message.sessionId) : undefined;
507
+ if (!session || session.ending) {
508
+ return send(connection.viewer, { type: "gone", sessionId: message.sessionId ?? null });
509
+ }
510
+ if (connection.session === session) return;
511
+ if (connection.session) this.detach(connection, "switched", false);
512
+ this.attachTo(connection, session, size);
513
+ }
514
+
515
+ private async input(connection: Connection, message: Record<string, unknown>): Promise<void> {
516
+ const session = connection.session;
517
+ if (!session || session.viewer !== connection.viewer) {
518
+ return send(connection.viewer, { type: "error", reason: "this connection views no session" });
519
+ }
520
+ if (typeof message.data !== "string") return;
521
+ if (Buffer.byteLength(message.data) > this.limits.inputBytes) {
522
+ return send(connection.viewer, { type: "error", reason: "input too large" });
523
+ }
524
+ session.process.write(message.data);
525
+ }
526
+
527
+ private async resize(connection: Connection, message: Record<string, unknown>): Promise<void> {
528
+ const session = connection.session;
529
+ if (!session || session.viewer !== connection.viewer) {
530
+ return send(connection.viewer, { type: "error", reason: "this connection views no session" });
531
+ }
532
+ const size = this.dimensions(message);
533
+ if (!size)
534
+ return send(connection.viewer, { type: "error", reason: "terminal size out of range" });
535
+ session.process.resize(size.cols, size.rows);
536
+ }
537
+
538
+ private detach(connection: Connection, reason: string, close: boolean): void {
539
+ const session = connection.session;
540
+ connection.session = null;
541
+ if (close) {
542
+ send(connection.viewer, { type: "detached", reason });
543
+ try {
544
+ connection.viewer.close(4000, reason);
545
+ } catch {
546
+ /** Already closed. */
547
+ }
548
+ }
549
+ if (!session || session.viewer !== connection.viewer) return;
550
+ session.viewer = null;
551
+ if (session.ending) return;
552
+ session.detachedAt = this.now();
553
+ session.expiry = setTimeout(() => {
554
+ void this.finish(session, { type: "ended", reason: "detach window expired" });
555
+ }, this.options.detachMs);
556
+ (session.expiry as { unref?: () => void }).unref?.();
557
+ }
558
+
559
+ /** TERM the group, KILL it after the grace period, and wait for the reap. */
560
+ private finish(session: Session, notice: Record<string, unknown>): Promise<void> {
561
+ if (session.ending) return session.ending;
562
+ if (session.expiry) clearTimeout(session.expiry);
563
+ session.expiry = null;
564
+ this.sessions.delete(session.id);
565
+ const viewer = session.viewer;
566
+ session.viewer = null;
567
+ if (viewer) {
568
+ send(viewer, notice);
569
+ const connection = [...this.connections].find((c) => c.viewer === viewer);
570
+ if (connection) connection.session = null;
571
+ }
572
+ const wait = (ms: number) =>
573
+ Promise.race([
574
+ session.process.exited.then(() => true),
575
+ new Promise<boolean>((resolve) => setTimeout(() => resolve(false), ms)),
576
+ ]);
577
+ session.ending = (async () => {
578
+ if (!session.exited) {
579
+ this.signal(session.process.pid, "SIGTERM");
580
+ await wait(this.limits.termGraceMs);
581
+ }
582
+ /** Always: descendants may outlive a leader that honoured TERM. */
583
+ this.signal(session.process.pid, "SIGKILL");
584
+ await wait(this.limits.reapMs);
585
+ try {
586
+ session.process.close();
587
+ } catch {
588
+ /** Already closed. */
589
+ }
590
+ })();
591
+ return session.ending;
592
+ }
593
+ }