Compare commits

..

9 Commits

Author SHA1 Message Date
Gabriel Brown 65aae85369 Update formatting on worker
Build and Push Spoon Images / quality (push) Successful in 1m27s
Build and Push Spoon Images / build-images (push) Successful in 7m13s
2026-06-24 08:40:52 -04:00
Gabriel Brown 5f7d56369f Lint staged now runs on worker 2026-06-24 08:39:31 -04:00
Gabriel Brown fd48dcfc28 Not sure what this change is but hey 2026-06-24 08:38:23 -04:00
Gabriel Brown 24a516c2b5 Terminal: job image tools (neovim/tmux), build-arg wiring, docs
- agent-job image: add neovim, tmux, less, unzip, wget, locales for the
  interactive shell (tmux powers cross-reconnect session persistence)
- Wire NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL as a build arg (Dockerfile +
  compose.yml) since NEXT_PUBLIC vars are inlined at build time
- docs/agent-terminal.md: architecture, env, nginx WS exposure, dev testing,
  security; note the build-time var in docs/compose.prod.yml
2026-06-24 08:27:10 -04:00
Gabriel Brown 15407e7e9c Workspace Terminal tab: xterm front end + short-lived token route
- New Terminal tab in the workspace shell, backed by xterm.js, that connects
  to the worker's PTY WebSocket using a short-lived token minted by
  /api/agent-jobs/:id/terminal-token (owner-auth'd, never exposes the worker
  secret to the browser)
- Site-matched xterm theme (light/dark, live theme switching), Victor Mono,
  binary stdin + JSON resize protocol, reconnect, graceful 'not configured'
  state when NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL is unset
- env: NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL (client), SPOON_AGENT_TERMINAL_SECRET
2026-06-24 08:23:58 -04:00
Gabriel Brown c1263b2e69 Worker: interactive terminal WebSocket bridge (PTY in workspace container)
- attachTerminalServer() upgrades /jobs/:id/terminal WS connections, verifying a
  short-lived job-scoped HMAC token (verifyTerminalToken) so the browser never
  holds the worker secret
- Bridges the socket to a bash PTY via dockerode exec (Tty) in a persistent
  per-job shell container (spoon-agent-term-<id>) mounting the workspace; binary
  frames = stdin, JSON text frames = resize; idle containers reaped after 30m
- New env: SPOON_AGENT_TERMINAL_IMAGE/SECRET/IDLE_MS (secret falls back to the
  shared worker internal token)
2026-06-24 08:16:39 -04:00
Gabriel Brown 1072cf10cd Editor: site-matched theme, Victor Mono font, no false TS errors
- Add spoon-dark/spoon-light Monaco themes built from the site's design tokens
  (teal --primary accent), switched by next-themes resolvedTheme
- Use Victor Mono (with ligatures + italic comments) for the editor font
- Disable Monaco's in-browser TS *semantic* diagnostics, which were false
  positives (no node_modules / path aliases in the browser) e.g. 'Cannot find
  module ~/server/auth'; keep real syntax-error reporting
2026-06-24 07:45:24 -04:00
Gabriel Brown ae90681d9b Add workspace loading state; auto-refresh diff on agent changes
- Gate the tree/diff/status loads (and 5s poll) on workspaceStatus being
  active/idle, so we no longer hammer the 'workspace is not active' endpoint
  while a worker is still picking up the job
- Show a 'Setting up your workspace…' pending state instead of surfacing
  startup as a console error / stale-workspace recovery box; escalate to a
  softer 'still waiting' hint after 90s if no worker picks it up
- Auto-reload the diff and file tree (debounced) whenever the agent records a
  workspace change or a turn starts/ends, so diffs appear without a manual
  Refresh
- The recovery box now only appears for a genuinely lost workspace (Convex
  reports active but the worker can't reach it)
2026-06-24 07:29:38 -04:00
Gabriel Brown bb471a0917 Improve workspace/chat diff viewer with syntax highlighting & per-file view
Replace the raw single-blob diff dump (Monaco, language=diff) and the plain
<pre> file diffs in chat with @git-diff-view/react:
- Parse the unified git diff into structured per-file entries (status,
  +/- counts, binary detection) via parseDiffFiles()
- Workspace Diff tab: collapsible per-file cards with status badges, line
  counts, syntax highlighting, and a Unified/Split toggle
- Agent chat: render each change's diff highlighted instead of plain text
- Theme follows next-themes resolvedTheme (light/dark)
2026-06-24 07:08:43 -04:00
35 changed files with 1686 additions and 214 deletions
+4
View File
@@ -19,14 +19,18 @@
"@octokit/rest": "^22.0.1", "@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "latest", "@opencode-ai/sdk": "latest",
"convex": "catalog:convex", "convex": "catalog:convex",
"dockerode": "^4.0.7",
"execa": "latest", "execa": "latest",
"ws": "catalog:",
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@spoon/eslint-config": "workspace:*", "@spoon/eslint-config": "workspace:*",
"@spoon/prettier-config": "workspace:*", "@spoon/prettier-config": "workspace:*",
"@spoon/tsconfig": "workspace:*", "@spoon/tsconfig": "workspace:*",
"@types/dockerode": "^3.3.42",
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/ws": "^8.18.1",
"eslint": "catalog:", "eslint": "catalog:",
"prettier": "catalog:", "prettier": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
+27 -10
View File
@@ -71,7 +71,8 @@ const textFromPart = (part: Record<string, unknown>) => {
}; };
const commandString = (value: unknown) => { const commandString = (value: unknown) => {
if (Array.isArray(value)) return value.map((part) => stringify(part)).join(' '); if (Array.isArray(value))
return value.map((part) => stringify(part)).join(' ');
return stringify(value); return stringify(value);
}; };
@@ -82,8 +83,7 @@ const toolNameFromRecord = (record: Record<string, unknown> | null) =>
record?.toolName ?? record?.toolName ??
record?.name ?? record?.name ??
record?.function ?? record?.function ??
(stringify(record?.type).toLowerCase().includes('exec') || (stringify(record?.type).toLowerCase().includes('exec') || record?.command
record?.command
? 'Command' ? 'Command'
: record?.type) ?? : record?.type) ??
'tool', 'tool',
@@ -132,7 +132,9 @@ const recordLooksLikeTool = (
recordType.includes('exec_command') || recordType.includes('exec_command') ||
recordType.includes('command') || recordType.includes('command') ||
recordType.includes('mcp') || recordType.includes('mcp') ||
Boolean(record?.tool ?? record?.tool_name ?? record?.name ?? record?.command) Boolean(
record?.tool ?? record?.tool_name ?? record?.name ?? record?.command,
)
); );
}; };
@@ -153,7 +155,10 @@ const normalizeCodexMsgEvent = (
); );
if (sessionId) events.push({ kind: 'session', sessionId }); if (sessionId) events.push({ kind: 'session', sessionId });
} }
if (msgType === 'agent_message_delta' || msgType === 'agent_reasoning_delta') { if (
msgType === 'agent_message_delta' ||
msgType === 'agent_reasoning_delta'
) {
const delta = stringify(msg.delta ?? msg.text); const delta = stringify(msg.delta ?? msg.text);
if (delta) events.push({ kind: 'assistant_delta', content: delta }); if (delta) events.push({ kind: 'assistant_delta', content: delta });
} }
@@ -177,7 +182,11 @@ const normalizeCodexMsgEvent = (
output: toolOutputFromRecord(msg), output: toolOutputFromRecord(msg),
}); });
} }
if (msgType === 'error' || msgType === 'turn_failed' || msgType === 'task_error') { if (
msgType === 'error' ||
msgType === 'turn_failed' ||
msgType === 'task_error'
) {
const message = stringify(msg.message ?? msg.error ?? msg); const message = stringify(msg.message ?? msg.error ?? msg);
if (isCodexConfigWarning(message)) { if (isCodexConfigWarning(message)) {
events.push({ kind: 'status', status: message }); events.push({ kind: 'status', status: message });
@@ -354,7 +363,8 @@ export const normalizeOpenCodeEvent = (
const event = asRecord(input); const event = asRecord(input);
if (!event) return []; if (!event) return [];
const type = stringify(event.type); const type = stringify(event.type);
const properties = asRecord(event.properties) ?? asRecord(event.data) ?? event; const properties =
asRecord(event.properties) ?? asRecord(event.data) ?? event;
const events: NormalizedAgentEvent[] = []; const events: NormalizedAgentEvent[] = [];
const sessionId = properties.sessionID ?? properties.sessionId; const sessionId = properties.sessionID ?? properties.sessionId;
if (typeof sessionId === 'string' && type.includes('session')) { if (typeof sessionId === 'string' && type.includes('session')) {
@@ -408,7 +418,8 @@ export const normalizeOpenCodeEvent = (
} }
if (type === 'file.edited') { if (type === 'file.edited') {
const file = properties.file; const file = properties.file;
if (typeof file === 'string') events.push({ kind: 'file_edited', path: file }); if (typeof file === 'string')
events.push({ kind: 'file_edited', path: file });
} }
if (type === 'command.executed') { if (type === 'command.executed') {
events.push({ events.push({
@@ -422,7 +433,9 @@ export const normalizeOpenCodeEvent = (
kind: 'permission_requested', kind: 'permission_requested',
externalRequestId: stringify(properties.permissionID ?? properties.id), externalRequestId: stringify(properties.permissionID ?? properties.id),
title: 'Permission requested', title: 'Permission requested',
body: stringify(properties.permission ?? properties.message ?? properties), body: stringify(
properties.permission ?? properties.message ?? properties,
),
metadata: stringify(properties), metadata: stringify(properties),
}); });
} }
@@ -443,7 +456,11 @@ export const normalizeOpenCodeEvent = (
}); });
} }
if (events.length === 0 && type) { if (events.length === 0 && type) {
events.push({ kind: 'status', status: type, metadata: stringify(properties) }); events.push({
kind: 'status',
status: type,
metadata: stringify(properties),
});
} }
return events; return events;
}; };
+15 -1
View File
@@ -25,13 +25,27 @@ export const env = {
process.env.SPOON_AGENT_CONTAINER_RUNTIME?.trim() ?? process.env.SPOON_AGENT_CONTAINER_RUNTIME?.trim() ??
process.env.SPOON_CONTAINER_RUNTIME?.trim() ?? process.env.SPOON_CONTAINER_RUNTIME?.trim() ??
'docker', 'docker',
containerVolumeOptions: process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS?.trim(), containerVolumeOptions:
process.env.SPOON_AGENT_CONTAINER_VOLUME_OPTIONS?.trim(),
containerAccess: containerAccess:
process.env.SPOON_AGENT_CONTAINER_ACCESS?.trim() === 'host_port' process.env.SPOON_AGENT_CONTAINER_ACCESS?.trim() === 'host_port'
? 'host_port' ? 'host_port'
: 'network', : 'network',
jobImage: jobImage:
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest', process.env.SPOON_AGENT_JOB_IMAGE?.trim() ?? 'spoon-agent-job:latest',
// Interactive terminal: image for the persistent shell container (defaults to
// the job image), the secret shared with the Next app for verifying terminal
// tokens, and how long an idle terminal container survives before cleanup.
terminalImage:
process.env.SPOON_AGENT_TERMINAL_IMAGE?.trim() ??
process.env.SPOON_AGENT_JOB_IMAGE?.trim() ??
'spoon-agent-job:latest',
terminalSecret:
process.env.SPOON_AGENT_TERMINAL_SECRET?.trim() ??
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN?.trim() ??
process.env.SPOON_WORKER_TOKEN?.trim() ??
'',
terminalIdleMs: intEnv('SPOON_AGENT_TERMINAL_IDLE_MS', 1_800_000),
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work', workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
hostWorkdir: process.env.SPOON_AGENT_HOST_WORKDIR?.trim(), hostWorkdir: process.env.SPOON_AGENT_HOST_WORKDIR?.trim(),
network: process.env.SPOON_AGENT_NETWORK?.trim(), network: process.env.SPOON_AGENT_NETWORK?.trim(),
+1 -2
View File
@@ -155,8 +155,7 @@ export const getWorktreeDiff = async (
if (diff.output.trim()) untrackedDiffs.push(diff.output); if (diff.output.trim()) untrackedDiffs.push(diff.output);
} }
return { return {
exitCode: exitCode: trackedDiff.exitCode === 0 && untracked.exitCode === 0 ? 0 : 1,
trackedDiff.exitCode === 0 && untracked.exitCode === 0 ? 0 : 1,
output: [trackedDiff.output, ...untrackedDiffs] output: [trackedDiff.output, ...untrackedDiffs]
.filter((part) => part.trim()) .filter((part) => part.trim())
.join('\n'), .join('\n'),
+5 -3
View File
@@ -1,5 +1,5 @@
import { createOpencodeClient } from '@opencode-ai/sdk';
import type { OpencodeClient } from '@opencode-ai/sdk'; import type { OpencodeClient } from '@opencode-ai/sdk';
import { createOpencodeClient } from '@opencode-ai/sdk';
import type { NormalizedAgentEvent } from './agent-events'; import type { NormalizedAgentEvent } from './agent-events';
import { normalizeOpenCodeEvent } from './agent-events'; import { normalizeOpenCodeEvent } from './agent-events';
@@ -115,11 +115,13 @@ export const replyOpenCodePermission = async (args: {
response: 'once' | 'always' | 'reject'; response: 'once' | 'always' | 'reject';
directory: string; directory: string;
}) => { }) => {
const result = await args.session.client.postSessionIdPermissionsPermissionId({ const result = await args.session.client.postSessionIdPermissionsPermissionId(
{
path: { id: args.session.sessionId, permissionID: args.permissionId }, path: { id: args.session.sessionId, permissionID: args.permissionId },
query: { directory: args.directory }, query: { directory: args.directory },
body: { response: args.response }, body: { response: args.response },
}); },
);
if (result.error) { if (result.error) {
throw new Error('OpenCode permission response was rejected.'); throw new Error('OpenCode permission response was rejected.');
} }
+16 -17
View File
@@ -1,5 +1,5 @@
import { execa } from 'execa';
import path from 'node:path'; import path from 'node:path';
import { execa } from 'execa';
import { env } from '../env'; import { env } from '../env';
@@ -74,6 +74,12 @@ const hostWorkspacePath = (workdir: string) => {
return path.join(env.hostWorkdir, relative); return path.join(env.hostWorkdir, relative);
}; };
export const containerVolumeSuffix = () =>
env.containerVolumeOptions ??
(containerRuntime().endsWith('podman') ? 'Z' : undefined);
export { hostWorkspacePath };
export const jobWorkspaceVolumeSpec = (workdir: string) => { export const jobWorkspaceVolumeSpec = (workdir: string) => {
const volumeOptions = const volumeOptions =
env.containerVolumeOptions ?? env.containerVolumeOptions ??
@@ -128,15 +134,9 @@ export const startWorkspaceContainer = async (args: {
publishTcpPort?: number; publishTcpPort?: number;
}) => { }) => {
await ensureJobImagePulled(); await ensureJobImagePulled();
await execa( await execa(containerRuntime(), ['rm', '-f', args.containerName], {
containerRuntime(), reject: false,
[ });
'rm',
'-f',
args.containerName,
],
{ reject: false },
);
const result = await execa( const result = await execa(
containerRuntime(), containerRuntime(),
[ [
@@ -171,7 +171,10 @@ export const startWorkspaceContainer = async (args: {
}; };
}; };
const getPublishedPort = async (containerName: string, containerPort: number) => { const getPublishedPort = async (
containerName: string,
containerPort: number,
) => {
const result = await execa( const result = await execa(
containerRuntime(), containerRuntime(),
['port', containerName, `${containerPort}/tcp`], ['port', containerName, `${containerPort}/tcp`],
@@ -311,14 +314,10 @@ export const stopWorkspaceContainer = async (containerName: string) => {
}; };
export const inspectWorkspaceContainer = async (containerName: string) => { export const inspectWorkspaceContainer = async (containerName: string) => {
const result = await execa( const result = await execa(containerRuntime(), ['inspect', containerName], {
containerRuntime(),
['inspect', containerName],
{
all: true, all: true,
reject: false, reject: false,
}, });
);
return { return {
exists: result.exitCode === 0, exists: result.exitCode === 0,
output: result.all, output: result.all,
+5 -2
View File
@@ -4,6 +4,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { env } from './env'; import { env } from './env';
import { attachTerminalServer } from './terminal';
import { import {
abortWorkspaceAgent, abortWorkspaceAgent,
cleanupOrphanedWorkspaces, cleanupOrphanedWorkspaces,
@@ -127,8 +128,9 @@ export const startWorkerServer = () => {
sendJson(response, 200, await abortWorkspaceAgent(route.jobId)); sendJson(response, 200, await abortWorkspaceAgent(route.jobId));
return; return;
} }
const interactionMatch = const interactionMatch = /^interactions\/([^/]+)\/reply$/.exec(
/^interactions\/([^/]+)\/reply$/.exec(route.action); route.action,
);
if (request.method === 'POST' && interactionMatch?.[1]) { if (request.method === 'POST' && interactionMatch?.[1]) {
const body = await parseJson<{ const body = await parseJson<{
externalRequestId?: string; externalRequestId?: string;
@@ -182,6 +184,7 @@ export const startWorkerServer = () => {
} }
})(); })();
}); });
attachTerminalServer(server);
server.listen(env.httpPort, () => { server.listen(env.httpPort, () => {
console.log( console.log(
`Spoon agent worker HTTP server listening on port ${env.httpPort}`, `Spoon agent worker HTTP server listening on port ${env.httpPort}`,
+29
View File
@@ -0,0 +1,29 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
// Short-lived, job-scoped token authorizing a browser terminal connection.
// Minted server-side by the Next app (which has verified job ownership) and
// verified here so the browser never sees the shared worker secret. Format:
// `${expiresAtMs}.${jobId}.${hmacSha256Hex}`
const signature = (payload: string, secret: string) =>
createHmac('sha256', secret).update(payload).digest('hex');
export const verifyTerminalToken = (
token: string,
jobId: string,
secret: string,
): boolean => {
if (!token || !secret) return false;
const parts = token.split('.');
if (parts.length !== 3) return false;
const [expRaw, tokenJobId, provided] = parts;
if (tokenJobId !== jobId) return false;
const exp = Number.parseInt(expRaw ?? '', 10);
if (!Number.isFinite(exp) || Date.now() > exp) return false;
const expected = signature(`${expRaw}.${tokenJobId}`, secret);
const providedBuf = Buffer.from(provided ?? '', 'hex');
const expectedBuf = Buffer.from(expected, 'hex');
return (
providedBuf.length === expectedBuf.length &&
timingSafeEqual(providedBuf, expectedBuf)
);
};
+187
View File
@@ -0,0 +1,187 @@
import type { Server } from 'node:http';
import type { Duplex } from 'node:stream';
import type { WebSocket } from 'ws';
import Docker from 'dockerode';
import { WebSocketServer } from 'ws';
import { env } from './env';
import { containerVolumeSuffix, hostWorkspacePath } from './runtime/docker';
import { verifyTerminalToken } from './terminal-token';
import { getTerminalWorkspace } from './worker';
const TERMINAL_IMAGE = env.terminalImage;
const IDLE_STOP_MS = env.terminalIdleMs;
const CONTAINER_WORKDIR = '/workspace/repo';
const docker = new Docker();
const containerName = (jobId: string) =>
`spoon-agent-term-${jobId.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
type Session = { connections: number; idleTimer?: NodeJS.Timeout };
const sessions = new Map<string, Session>();
const ensureTerminalContainer = async (jobId: string, workdir: string) => {
const name = containerName(jobId);
const container = docker.getContainer(name);
const info = await container.inspect().catch(() => null);
if (info?.State.Running) return container;
if (info && !info.State.Running) {
await container.remove({ force: true }).catch(() => undefined);
}
const suffix = containerVolumeSuffix();
const source = hostWorkspacePath(workdir);
const created = await docker.createContainer({
name,
Image: TERMINAL_IMAGE,
Cmd: ['sleep', 'infinity'],
WorkingDir: CONTAINER_WORKDIR,
Tty: false,
Labels: { 'spoon.agent.terminal': jobId },
HostConfig: {
Binds: [`${source}:/workspace${suffix ? `:${suffix}` : ''}`],
NetworkMode: env.network,
Memory: 4 * 1024 * 1024 * 1024,
AutoRemove: false,
},
});
await created.start();
return created;
};
const stopTerminalContainer = async (jobId: string) => {
await docker
.getContainer(containerName(jobId))
.remove({ force: true })
.catch(() => undefined);
sessions.delete(jobId);
};
const scheduleIdleStop = (jobId: string) => {
const session = sessions.get(jobId);
if (!session || session.connections > 0) return;
session.idleTimer = setTimeout(() => {
void stopTerminalContainer(jobId);
}, IDLE_STOP_MS);
};
const bridge = async (ws: WebSocket, jobId: string) => {
const workspace = getTerminalWorkspace(jobId);
if (!workspace) {
ws.close(1011, 'Workspace is not active.');
return;
}
const session = sessions.get(jobId) ?? { connections: 0 };
if (session.idleTimer) clearTimeout(session.idleTimer);
session.idleTimer = undefined;
session.connections += 1;
sessions.set(jobId, session);
let stream: Duplex | undefined;
let exec: Docker.Exec | undefined;
try {
const container = await ensureTerminalContainer(jobId, workspace.workdir);
exec = await container.exec({
// Reattach a persistent tmux session across reconnects when tmux is
// available; otherwise fall back to a plain login shell.
Cmd: [
'/bin/bash',
'-lc',
'exec tmux new-session -A -s spoon 2>/dev/null || exec bash -l',
],
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
WorkingDir: CONTAINER_WORKDIR,
Env: [
'TERM=xterm-256color',
...workspace.secrets.map((s) => `${s.name}=${s.value}`),
],
});
stream = await exec.start({ hijack: true, stdin: true, Tty: true });
} catch (error) {
ws.close(
1011,
`Failed to start terminal: ${error instanceof Error ? error.message : 'unknown error'}`,
);
session.connections -= 1;
scheduleIdleStop(jobId);
return;
}
const activeStream = stream;
const activeExec = exec;
activeStream.on('data', (chunk: Buffer) => {
if (ws.readyState === ws.OPEN) ws.send(chunk, { binary: true });
});
activeStream.on('end', () => ws.close());
activeStream.on('error', () => ws.close());
ws.on('message', (data: Buffer, isBinary: boolean) => {
if (isBinary) {
activeStream.write(data);
return;
}
// Text frames are control messages (resize); anything else is treated as
// input for resilience.
try {
const message = JSON.parse(data.toString('utf8')) as {
type?: string;
cols?: number;
rows?: number;
};
if (message.type === 'resize' && message.cols && message.rows) {
void activeExec.resize({ w: message.cols, h: message.rows });
return;
}
} catch {
// fall through: treat as raw input
}
activeStream.write(data);
});
const cleanup = () => {
activeStream.end();
const current = sessions.get(jobId);
if (current) {
current.connections = Math.max(0, current.connections - 1);
scheduleIdleStop(jobId);
}
};
ws.on('close', cleanup);
ws.on('error', cleanup);
};
/**
* Attaches the interactive-terminal WebSocket endpoint to the worker's HTTP
* server. Browser connects to `/jobs/:jobId/terminal?token=…` with a short-lived
* token minted by the Next app (which has already verified job ownership).
*/
export const attachTerminalServer = (server: Server) => {
if (env.runtime !== 'docker') return;
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url ?? '', `http://localhost:${env.httpPort}`);
const match = /^\/jobs\/([^/]+)\/terminal$/.exec(url.pathname);
if (!match?.[1]) {
socket.destroy();
return;
}
const jobId = decodeURIComponent(match[1]);
const token = url.searchParams.get('token') ?? '';
if (!verifyTerminalToken(token, jobId, env.terminalSecret)) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
void bridge(ws, jobId);
});
});
console.log('Spoon agent worker terminal WebSocket endpoint enabled.');
};
+36 -11
View File
@@ -1,3 +1,4 @@
import { randomBytes } from 'node:crypto';
import { import {
access, access,
mkdir, mkdir,
@@ -7,7 +8,6 @@ import {
stat, stat,
writeFile, writeFile,
} from 'node:fs/promises'; } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import path from 'node:path'; import path from 'node:path';
import { ConvexHttpClient } from 'convex/browser'; import { ConvexHttpClient } from 'convex/browser';
@@ -15,6 +15,7 @@ import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js'; import { api } from '@spoon/backend/convex/_generated/api.js';
import type { NormalizedAgentEvent } from './agent-events'; import type { NormalizedAgentEvent } from './agent-events';
import type { OpenCodeSession } from './opencode-session';
import { normalizeCodexJsonLine } from './agent-events'; import { normalizeCodexJsonLine } from './agent-events';
import { import {
codexContainerRepo, codexContainerRepo,
@@ -30,7 +31,6 @@ import {
run, run,
} from './git'; } from './git';
import { getInstallationToken, openDraftPullRequest } from './github'; import { getInstallationToken, openDraftPullRequest } from './github';
import type { OpenCodeSession } from './opencode-session';
import { import {
abortOpenCodeSession, abortOpenCodeSession,
createOpenCodeSession, createOpenCodeSession,
@@ -432,7 +432,7 @@ const opencodeModel = (claim: Claim) => {
const codexModel = (claim: Claim) => { const codexModel = (claim: Claim) => {
const model = claim.aiProviderProfile?.model ?? claim.openai.model; const model = claim.aiProviderProfile?.model ?? claim.openai.model;
return model.includes('/') ? model.split('/').at(-1) ?? model : model; return model.includes('/') ? (model.split('/').at(-1) ?? model) : model;
}; };
const codexModelArgs = (claim: Claim) => const codexModelArgs = (claim: Claim) =>
@@ -531,8 +531,7 @@ const handleAgentEvent = async (args: {
return; return;
} }
if (event.kind === 'tool_started' || event.kind === 'tool_completed') { if (event.kind === 'tool_started' || event.kind === 'tool_completed') {
const detail = const detail = event.kind === 'tool_started' ? event.input : event.output;
event.kind === 'tool_started' ? event.input : event.output;
await appendMessage({ await appendMessage({
jobId, jobId,
role: 'tool', role: 'tool',
@@ -653,14 +652,17 @@ const ensureOpenCodeSession = async (workspace: ActiveWorkspace) => {
directory: '/workspace/repo', directory: '/workspace/repo',
title: workspace.claim.job.prompt.slice(0, 80) || 'Spoon workspace', title: workspace.claim.job.prompt.slice(0, 80) || 'Spoon workspace',
onEvent: async (event) => { onEvent: async (event) => {
const messageId = workspaceCurrentMessage.get(workspace.claim.job._id); const messageId = workspaceCurrentMessage.get(
workspace.claim.job._id,
);
if (!messageId) return; if (!messageId) return;
await handleAgentEvent({ await handleAgentEvent({
workspace, workspace,
event, event,
assistantMessageId: messageId, assistantMessageId: messageId,
assistantContent: assistantContent: workspaceCurrentContent.get(
workspaceCurrentContent.get(workspace.claim.job._id) ?? { workspace.claim.job._id,
) ?? {
value: '', value: '',
}, },
}); });
@@ -1462,6 +1464,17 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => {
return { success: true }; return { success: true };
}; };
// Non-throwing accessor for the interactive terminal: returns the active
// workspace's mount info, or null when the workspace is not active here.
export const getTerminalWorkspace = (jobId: string) => {
const workspace = activeWorkspaces.get(jobId);
if (!workspace) return null;
return {
workdir: workspace.workdir,
secrets: workspace.claim.secrets,
};
};
export const getWorkspaceAgentStatus = (jobId: string) => { export const getWorkspaceAgentStatus = (jobId: string) => {
const workspace = resolveWorkspace(jobId); const workspace = resolveWorkspace(jobId);
return { return {
@@ -1480,7 +1493,12 @@ export const abortWorkspaceAgent = async (jobId: string) => {
workspace.agentTurnActive = false; workspace.agentTurnActive = false;
workspace.resolveTurn?.(); workspace.resolveTurn?.();
workspace.resolveTurn = undefined; workspace.resolveTurn = undefined;
await appendEvent(workspace.claim.job._id, 'warn', 'cleanup', 'Agent turn aborted.'); await appendEvent(
workspace.claim.job._id,
'warn',
'cleanup',
'Agent turn aborted.',
);
return { success: true }; return { success: true };
} }
if (workspace.runtimeMode === 'codex_exec') { if (workspace.runtimeMode === 'codex_exec') {
@@ -1594,7 +1612,13 @@ export const sendWorkspaceMessage = async (
const secretEnv = Object.fromEntries( const secretEnv = Object.fromEntries(
claim.secrets.map((secret) => [secret.name, secret.value]), claim.secrets.map((secret) => [secret.name, secret.value]),
); );
const result = await run('bash', ['-lc', `opencode run --format json --model ${quoteShell(opencodeModel(claim))} ${quoteShell(prompt)}`], { const result = await run(
'bash',
[
'-lc',
`opencode run --format json --model ${quoteShell(opencodeModel(claim))} ${quoteShell(prompt)}`,
],
{
cwd: workspace.repoDir, cwd: workspace.repoDir,
env: { env: {
...aiEnv, ...aiEnv,
@@ -1602,7 +1626,8 @@ export const sendWorkspaceMessage = async (
}, },
redact, redact,
timeoutMs: env.jobTimeoutMs, timeoutMs: env.jobTimeoutMs,
}); },
);
await updateMessage({ await updateMessage({
messageId: assistantMessageId, messageId: assistantMessageId,
status: result.exitCode === 0 ? 'completed' : 'failed', status: result.exitCode === 0 ? 'completed' : 'failed',
@@ -271,7 +271,8 @@ describe('agent event normalization', () => {
externalRequestId: 'perm-1', externalRequestId: 'perm-1',
title: 'Permission requested', title: 'Permission requested',
body: 'Run bun test?', body: 'Run bun test?',
metadata: '{\n "permissionID": "perm-1",\n "message": "Run bun test?"\n}', metadata:
'{\n "permissionID": "perm-1",\n "message": "Run bun test?"\n}',
}); });
expect( expect(
@@ -1,4 +1,11 @@
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; import {
mkdir,
mkdtemp,
readFile,
rm,
stat,
writeFile,
} from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterEach, describe, expect, test } from 'vitest'; import { afterEach, describe, expect, test } from 'vitest';
@@ -3,7 +3,6 @@ import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, test } from 'vitest'; import { afterEach, beforeEach, describe, expect, test } from 'vitest';
type TestWorkspace = { type TestWorkspace = {
@@ -53,7 +52,9 @@ const writeConfig = async (
config: Record<string, unknown> | string, config: Record<string, unknown> | string,
) => { ) => {
const content = const content =
typeof config === 'string' ? config : `${JSON.stringify(config, null, 2)}\n`; typeof config === 'string'
? config
: `${JSON.stringify(config, null, 2)}\n`;
await writeFile(configPath(workspace), content); await writeFile(configPath(workspace), content);
}; };
@@ -0,0 +1,42 @@
import { createHmac } from 'node:crypto';
import { describe, expect, test } from 'vitest';
import { verifyTerminalToken } from '../../src/terminal-token';
const mint = (jobId: string, expiresAt: number, secret: string) => {
const payload = `${expiresAt}.${jobId}`;
const sig = createHmac('sha256', secret).update(payload).digest('hex');
return `${payload}.${sig}`;
};
describe('verifyTerminalToken', () => {
const secret = 'test-secret';
test('accepts a valid, unexpired, job-matched token', () => {
const token = mint('job1', Date.now() + 60_000, secret);
expect(verifyTerminalToken(token, 'job1', secret)).toBe(true);
});
test('rejects an expired token', () => {
const token = mint('job1', Date.now() - 1, secret);
expect(verifyTerminalToken(token, 'job1', secret)).toBe(false);
});
test('rejects a token minted for another job', () => {
const token = mint('job1', Date.now() + 60_000, secret);
expect(verifyTerminalToken(token, 'job2', secret)).toBe(false);
});
test('rejects a token signed with a different secret', () => {
const token = mint('job1', Date.now() + 60_000, 'other-secret');
expect(verifyTerminalToken(token, 'job1', secret)).toBe(false);
});
test('rejects malformed input and an empty secret', () => {
expect(verifyTerminalToken('garbage', 'job1', secret)).toBe(false);
expect(verifyTerminalToken('', 'job1', secret)).toBe(false);
expect(
verifyTerminalToken(mint('job1', Date.now() + 1000, ''), 'job1', ''),
).toBe(false);
});
});
+5
View File
@@ -21,16 +21,21 @@
}, },
"dependencies": { "dependencies": {
"@convex-dev/auth": "catalog:convex", "@convex-dev/auth": "catalog:convex",
"@git-diff-view/react": "^0.1.6",
"@monaco-editor/react": "latest", "@monaco-editor/react": "latest",
"@sentry/nextjs": "^10.46.0", "@sentry/nextjs": "^10.46.0",
"@spoon/backend": "workspace:*", "@spoon/backend": "workspace:*",
"@spoon/ui": "workspace:*", "@spoon/ui": "workspace:*",
"@t3-oss/env-nextjs": "^0.13.11", "@t3-oss/env-nextjs": "^0.13.11",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"convex": "catalog:convex", "convex": "catalog:convex",
"monaco-editor": "latest", "monaco-editor": "latest",
"monaco-vim": "latest", "monaco-vim": "latest",
"next": "^16.2.1", "next": "^16.2.1",
"next-plausible": "^3.12.5", "next-plausible": "^3.12.5",
"next-themes": "^0.4.6",
"react": "catalog:react19", "react": "catalog:react19",
"react-dom": "catalog:react19", "react-dom": "catalog:react19",
"require-in-the-middle": "^7.5.2", "require-in-the-middle": "^7.5.2",
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { mintTerminalToken, withOwnedJob } from '@/lib/agent-worker-proxy';
export const GET = async (
_request: Request,
context: { params: Promise<{ jobId: string }> },
) =>
await withOwnedJob(context, (jobId) => {
const minted = mintTerminalToken(jobId);
return Promise.resolve(
minted
? NextResponse.json(minted)
: NextResponse.json(
{ error: 'Terminal is not configured on this deployment.' },
{ status: 503 },
),
);
});
+9 -2
View File
@@ -1,5 +1,5 @@
import type { Metadata, Viewport } from 'next'; import type { Metadata, Viewport } from 'next';
import { Geist, Geist_Mono } from 'next/font/google'; import { Geist, Geist_Mono, Victor_Mono } from 'next/font/google';
import { env } from '@/env'; import { env } from '@/env';
import '@/app/styles.css'; import '@/app/styles.css';
@@ -30,6 +30,13 @@ const geistMono = Geist_Mono({
subsets: ['latin'], subsets: ['latin'],
variable: '--font-geist-mono', variable: '--font-geist-mono',
}); });
// Used by the workspace code editor (and, later, the terminal). Includes the
// italic cursive style for comments via Monaco's italic token styling.
const victorMono = Victor_Mono({
subsets: ['latin'],
variable: '--font-victor-mono',
display: 'swap',
});
const RootLayout = ({ const RootLayout = ({
children, children,
@@ -44,7 +51,7 @@ const RootLayout = ({
> >
<html lang='en' suppressHydrationWarning> <html lang='en' suppressHydrationWarning>
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} ${victorMono.variable} antialiased`}
> >
<ThemeProvider <ThemeProvider
attribute='class' attribute='class'
@@ -14,7 +14,8 @@ import { toast } from 'sonner';
import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Doc } from '@spoon/backend/convex/_generated/dataModel.js';
import { Badge, Button, Textarea } from '@spoon/ui'; import { Badge, Button, Textarea } from '@spoon/ui';
import { extractFileDiff } from './diff-utils'; import { DiffFileView, useDiffTheme } from './diff-file-view';
import { parseDiffFileForPath } from './diff-utils';
type ActivityFilter = 'all' | 'chat' | 'activity' | 'files' | 'errors'; type ActivityFilter = 'all' | 'chat' | 'activity' | 'files' | 'errors';
@@ -67,6 +68,7 @@ export const AgentThread = ({
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [replying, setReplying] = useState<string>(); const [replying, setReplying] = useState<string>();
const [filter, setFilter] = useState<ActivityFilter>('all'); const [filter, setFilter] = useState<ActivityFilter>('all');
const diffTheme = useDiffTheme();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const chatMessages = useMemo( const chatMessages = useMemo(
() => () =>
@@ -317,7 +319,13 @@ export const AgentThread = ({
</pre> </pre>
</article> </article>
))} ))}
{visibleChanges.map((change) => ( {visibleChanges.map((change) => {
const changedFile = parseDiffFileForPath(change.diff, change.path);
const hasDiff = Boolean(changedFile && !changedFile.isBinary);
const hasRenderableHunk = Boolean(
changedFile && hasDiff && changedFile.hunkText.includes('@@'),
);
return (
<article <article
key={change._id} key={change._id}
className='border-border bg-background rounded-md border p-3 text-sm' className='border-border bg-background rounded-md border p-3 text-sm'
@@ -332,10 +340,20 @@ export const AgentThread = ({
</div> </div>
<p className='text-muted-foreground mt-1 text-xs capitalize'> <p className='text-muted-foreground mt-1 text-xs capitalize'>
{change.source} {change.changeType} {change.source} {change.changeType}
{changedFile ? (
<span className='ml-2 font-mono normal-case'>
<span className='text-emerald-500'>
+{changedFile.additions}
</span>{' '}
<span className='text-red-500'>
{changedFile.deletions}
</span>
</span>
) : null}
</p> </p>
</div> </div>
<div className='flex flex-none items-center gap-2'> <div className='flex flex-none items-center gap-2'>
{extractFileDiff(change.diff, change.path) ? ( {hasDiff ? (
<Button <Button
type='button' type='button'
variant='outline' variant='outline'
@@ -357,18 +375,24 @@ export const AgentThread = ({
) : null} ) : null}
</div> </div>
</div> </div>
{extractFileDiff(change.diff, change.path) ? ( {hasRenderableHunk && changedFile ? (
<details className='mt-3'> <details className='mt-3'>
<summary className='text-muted-foreground cursor-pointer text-xs'> <summary className='text-muted-foreground cursor-pointer text-xs'>
File diff File diff
</summary> </summary>
<pre className='bg-muted mt-2 max-h-72 overflow-auto rounded p-2 text-xs whitespace-pre-wrap'> <div className='border-border mt-2 max-h-72 overflow-auto rounded border'>
{extractFileDiff(change.diff, change.path)} <DiffFileView
</pre> file={changedFile}
mode='unified'
theme={diffTheme}
fontSize={11}
/>
</div>
</details> </details>
) : null} ) : null}
</article> </article>
))} );
})}
{visibleEvents.slice(-80).map((event) => ( {visibleEvents.slice(-80).map((event) => (
<article <article
key={event._id} key={event._id}
@@ -3,7 +3,13 @@
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'; import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useMutation, useQuery } from 'convex/react'; import { useMutation, useQuery } from 'convex/react';
import { FileCode, GitCompare, MessagesSquare } from 'lucide-react'; import {
FileCode,
GitCompare,
Loader2,
MessagesSquare,
SquareTerminal,
} from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
@@ -34,6 +40,9 @@ import { FileTabs } from './file-tabs';
import { FileTree } from './file-tree'; import { FileTree } from './file-tree';
import { JobStatusBar } from './job-status-bar'; import { JobStatusBar } from './job-status-bar';
import { WorkspaceActions } from './workspace-actions'; import { WorkspaceActions } from './workspace-actions';
import { WorkspaceTerminal } from './workspace-terminal';
type WorkspaceTab = 'editor' | 'diff' | 'thread' | 'terminal';
type OpenFileState = { type OpenFileState = {
path: string; path: string;
@@ -81,9 +90,8 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
const [focusedDiffPath, setFocusedDiffPath] = useState<string>(); const [focusedDiffPath, setFocusedDiffPath] = useState<string>();
const [workspaceError, setWorkspaceError] = useState<string>(); const [workspaceError, setWorkspaceError] = useState<string>();
const [agentTurnActive, setAgentTurnActive] = useState(false); const [agentTurnActive, setAgentTurnActive] = useState(false);
const [activeWorkspaceTab, setActiveWorkspaceTab] = useState< const [activeWorkspaceTab, setActiveWorkspaceTab] =
'editor' | 'diff' | 'thread' useState<WorkspaceTab>('editor');
>('editor');
const [pendingOverwrite, setPendingOverwrite] = useState<PendingOverwrite>(); const [pendingOverwrite, setPendingOverwrite] = useState<PendingOverwrite>();
const [pendingClosePath, setPendingClosePath] = useState<string>(); const [pendingClosePath, setPendingClosePath] = useState<string>();
@@ -94,6 +102,25 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
) || ) ||
['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? ''); ['stopped', 'expired', 'failed'].includes(job.workspaceStatus ?? '');
// The worker only exposes the live runtime (tree/diff/file endpoints) once it
// has claimed the job and materialized the workspace (workspaceStatus
// 'active'/'idle'). Before that, hitting those endpoints returns "workspace is
// not active" — which is expected startup, not an error.
const workspaceReady = ['active', 'idle'].includes(
job?.workspaceStatus ?? '',
);
const workspaceFailed =
['failed', 'cancelled', 'timed_out'].includes(job?.status ?? '') ||
['stopped', 'expired', 'failed'].includes(job?.workspaceStatus ?? '');
// Waiting for a worker to pick up the job and start the runtime.
const workspacePending =
Boolean(job) &&
!workspaceReady &&
!workspaceFailed &&
['queued', 'claimed', 'preparing', 'running', 'checks_running'].includes(
job?.status ?? '',
);
const loadTree = useCallback(async () => { const loadTree = useCallback(async () => {
const response = await fetch(`/api/agent-jobs/${jobId}/tree`); const response = await fetch(`/api/agent-jobs/${jobId}/tree`);
if (!response.ok) throw new Error(await response.text()); if (!response.ok) throw new Error(await response.text());
@@ -181,31 +208,61 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
); );
useEffect(() => { useEffect(() => {
if (!job) return; if (!workspaceReady) return;
const handleError = (error: unknown) => {
console.error(error);
setWorkspaceError(error instanceof Error ? error.message : String(error));
};
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
void loadTree().catch((error: unknown) => { void loadTree().catch(handleError);
console.error(error); void loadDiff().catch(handleError);
setWorkspaceError(
error instanceof Error ? error.message : String(error),
);
});
void loadDiff().catch((error: unknown) => {
console.error(error);
setWorkspaceError(
error instanceof Error ? error.message : String(error),
);
});
void loadAgentStatus(); void loadAgentStatus();
}, 0); }, 0);
return () => window.clearTimeout(timeout); return () => window.clearTimeout(timeout);
}, [job, loadAgentStatus, loadDiff, loadTree]); }, [workspaceReady, loadAgentStatus, loadDiff, loadTree]);
useEffect(() => { useEffect(() => {
if (!workspaceReady) return;
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
void loadAgentStatus(); void loadAgentStatus();
}, 5_000); }, 5_000);
return () => window.clearInterval(interval); return () => window.clearInterval(interval);
}, [loadAgentStatus]); }, [workspaceReady, loadAgentStatus]);
// Surface a gentle "taking longer than usual" hint if a worker never picks the
// job up (e.g. the worker is offline) instead of spinning forever.
const [pendingTooLong, setPendingTooLong] = useState(false);
useEffect(() => {
if (!workspacePending) return;
const timer = window.setTimeout(() => setPendingTooLong(true), 90_000);
return () => {
window.clearTimeout(timer);
setPendingTooLong(false);
};
}, [workspacePending]);
// Refresh the tree and diff whenever the agent records a workspace change
// (file edit / tool call that touched files) or a turn starts/ends, so the
// diff viewer stays current without a manual refresh. Rapid bursts of changes
// debounce into a single reload via the timeout cleanup.
const workspaceChangeSignature = workspaceChanges.reduce(
(latest, change) => Math.max(latest, change._creationTime),
0,
);
useEffect(() => {
if (!workspaceReady) return;
const timeout = window.setTimeout(() => {
void loadDiff().catch(() => undefined);
void loadTree().catch(() => undefined);
}, 200);
return () => window.clearTimeout(timeout);
}, [
workspaceChangeSignature,
agentTurnActive,
workspaceReady,
loadDiff,
loadTree,
]);
useEffect(() => { useEffect(() => {
if (!uiState || hydratedUiState) return; if (!uiState || hydratedUiState) return;
@@ -418,6 +475,31 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
return ( return (
<main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'> <main className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[720px] flex-col overflow-hidden rounded-md border'>
<JobStatusBar job={job} /> <JobStatusBar job={job} />
{workspacePending && !workspaceError ? (
<div className='border-border bg-background border-b p-4'>
<div
className={`flex items-center gap-3 rounded-md border p-4 ${
pendingTooLong
? 'border-amber-500/40 bg-amber-500/5'
: 'border-border bg-muted/30'
}`}
>
<Loader2 className='text-muted-foreground size-5 flex-none animate-spin' />
<div>
<p className='font-medium'>
{pendingTooLong
? 'Still waiting for a worker…'
: 'Setting up your workspace…'}
</p>
<p className='text-muted-foreground text-sm'>
{pendingTooLong
? 'This is taking longer than usual — the worker may be busy or offline. It will start automatically once a worker is available.'
: 'Waiting for a worker to pick up this job. Files and diffs will appear automatically once the agent starts.'}
</p>
</div>
</div>
</div>
) : null}
{workspaceError ? ( {workspaceError ? (
<div className='border-border bg-background border-b p-4'> <div className='border-border bg-background border-b p-4'>
<div className='border-destructive/40 bg-destructive/5 rounded-md border p-4'> <div className='border-destructive/40 bg-destructive/5 rounded-md border p-4'>
@@ -501,7 +583,7 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
<Tabs <Tabs
value={activeWorkspaceTab} value={activeWorkspaceTab}
onValueChange={(value) => onValueChange={(value) =>
setActiveWorkspaceTab(value as 'editor' | 'diff' | 'thread') setActiveWorkspaceTab(value as WorkspaceTab)
} }
className='flex min-h-0 flex-1 flex-col' className='flex min-h-0 flex-1 flex-col'
> >
@@ -520,6 +602,13 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
<GitCompare className='size-4' /> <GitCompare className='size-4' />
Diff viewer Diff viewer
</TabsTrigger> </TabsTrigger>
<TabsTrigger
value='terminal'
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm'
>
<SquareTerminal className='size-4' />
Terminal
</TabsTrigger>
<TabsTrigger <TabsTrigger
value='thread' value='thread'
className='data-active:bg-background data-active:text-foreground data-active:shadow-sm 2xl:hidden' className='data-active:bg-background data-active:text-foreground data-active:shadow-sm 2xl:hidden'
@@ -568,6 +657,15 @@ export const AgentWorkspaceShell = ({ jobId }: { jobId: Id<'agentJobs'> }) => {
}} }}
/> />
</TabsContent> </TabsContent>
<TabsContent
value='terminal'
className='m-0 min-h-0 flex-1 overflow-hidden'
>
<WorkspaceTerminal
jobId={jobId}
active={activeWorkspaceTab === 'terminal' && workspaceReady}
/>
</TabsContent>
<TabsContent value='diff' className='m-0 min-h-0 flex-1'> <TabsContent value='diff' className='m-0 min-h-0 flex-1'>
<DiffViewer <DiffViewer
diff={diff} diff={diff}
@@ -2,15 +2,26 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { useTheme } from 'next-themes';
import { Button, Switch } from '@spoon/ui'; import { Button, Switch } from '@spoon/ui';
import type { MonacoLike } from './monaco-theme';
import { languageForPath } from './languages'; import { languageForPath } from './languages';
import {
configureSpoonMonaco,
remeasureFontsWhenReady,
SPOON_DARK,
SPOON_LIGHT,
} from './monaco-theme';
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), { const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
ssr: false, ssr: false,
}); });
const EDITOR_FONT_FAMILY =
"var(--font-victor-mono), 'Geist Mono', ui-monospace, SFMono-Regular, monospace";
type MonacoEditorInstance = { type MonacoEditorInstance = {
getModel?: () => unknown; getModel?: () => unknown;
}; };
@@ -42,6 +53,8 @@ export const CodeEditor = ({
const editorRef = useRef<MonacoEditorInstance | null>(null); const editorRef = useRef<MonacoEditorInstance | null>(null);
const vimRef = useRef<VimMode | null>(null); const vimRef = useRef<VimMode | null>(null);
const statusRef = useRef<HTMLDivElement | null>(null); const statusRef = useRef<HTMLDivElement | null>(null);
const { resolvedTheme } = useTheme();
const editorTheme = resolvedTheme === 'light' ? SPOON_LIGHT : SPOON_DARK;
useEffect(() => { useEffect(() => {
const editor = editorRef.current; const editor = editorRef.current;
@@ -115,14 +128,23 @@ export const CodeEditor = ({
path={path} path={path}
language={languageForPath(path)} language={languageForPath(path)}
value={content} value={content}
theme='vs-dark' theme={editorTheme}
beforeMount={(monaco) => {
configureSpoonMonaco(monaco as unknown as MonacoLike);
}}
options={{ options={{
readOnly, readOnly,
minimap: { enabled: false }, minimap: { enabled: false },
fontFamily: EDITOR_FONT_FAMILY,
fontLigatures: true,
fontSize: 13, fontSize: 13,
lineHeight: 1.6,
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
wordWrap: 'on', wordWrap: 'on',
automaticLayout: true, automaticLayout: true,
smoothScrolling: true,
cursorSmoothCaretAnimation: 'on',
padding: { top: 12, bottom: 12 },
scrollbar: { alwaysConsumeMouseWheel: false }, scrollbar: { alwaysConsumeMouseWheel: false },
quickSuggestions: true, quickSuggestions: true,
suggestOnTriggerCharacters: true, suggestOnTriggerCharacters: true,
@@ -131,8 +153,9 @@ export const CodeEditor = ({
bracketPairColorization: { enabled: true }, bracketPairColorization: { enabled: true },
renderWhitespace: 'selection', renderWhitespace: 'selection',
}} }}
onMount={(editor) => { onMount={(editor, monaco) => {
editorRef.current = editor as MonacoEditorInstance; editorRef.current = editor as MonacoEditorInstance;
remeasureFontsWhenReady(monaco as unknown as MonacoLike);
}} }}
onChange={(next) => { onChange={(next) => {
const nextValue = next ?? ''; const nextValue = next ?? '';
@@ -0,0 +1,68 @@
'use client';
import { DiffModeEnum, DiffView } from '@git-diff-view/react';
import { useTheme } from 'next-themes';
import '@git-diff-view/react/styles/diff-view.css';
import type { ParsedDiffFile } from './diff-utils';
export type DiffMode = 'unified' | 'split';
/** Resolves the git-diff-view theme from next-themes, defaulting to dark. */
export const useDiffTheme = (): 'light' | 'dark' => {
const { resolvedTheme } = useTheme();
return resolvedTheme === 'light' ? 'light' : 'dark';
};
/**
* Renders one file's diff with syntax highlighting. Falls back to a short note
* for binary files and metadata-only changes (pure renames / mode changes) that
* have no hunks to display.
*/
export const DiffFileView = ({
file,
mode,
theme,
fontSize = 12,
wrap = false,
}: {
file: ParsedDiffFile;
mode: DiffMode;
theme: 'light' | 'dark';
fontSize?: number;
wrap?: boolean;
}) => {
if (file.isBinary) {
return (
<div className='text-muted-foreground px-3 py-2 text-xs'>
Binary file not shown.
</div>
);
}
if (!file.hunkText.includes('@@')) {
return (
<div className='text-muted-foreground px-3 py-2 text-xs'>
{file.status === 'renamed'
? 'Renamed with no content changes.'
: 'No content changes.'}
</div>
);
}
return (
<DiffView
data={{
oldFile: { fileName: file.oldPath || file.displayPath },
newFile: { fileName: file.newPath || file.displayPath },
hunks: [file.hunkText],
}}
diffViewMode={
mode === 'split' ? DiffModeEnum.Split : DiffModeEnum.Unified
}
diffViewTheme={theme}
diffViewHighlight
diffViewWrap={wrap}
diffViewFontSize={fontSize}
/>
);
};
@@ -1,3 +1,112 @@
export type DiffFileStatus = 'added' | 'deleted' | 'modified' | 'renamed';
export type ParsedDiffFile = {
id: string;
oldPath: string;
newPath: string;
/** Path to show in the UI (new path, or old path for deletions). */
displayPath: string;
status: DiffFileStatus;
additions: number;
deletions: number;
isBinary: boolean;
/** The full per-file unified diff section, fed as-is to the diff renderer. */
hunkText: string;
};
const stripABPrefix = (value: string) => value.replace(/^[ab]\//, '');
/**
* Splits a raw unified git diff into structured, per-file entries. Replaces the
* "one giant blob" rendering: each file can be shown, counted, and highlighted
* independently.
*/
export const parseDiffFiles = (diff: string | undefined): ParsedDiffFile[] => {
if (!diff?.trim()) return [];
const sections: string[][] = [];
let current: string[] | null = null;
for (const line of diff.split('\n')) {
if (line.startsWith('diff --git ')) {
if (current) sections.push(current);
current = [line];
continue;
}
current?.push(line);
}
if (current) sections.push(current);
return sections.map((sectionLines, index) => {
const header = sectionLines[0] ?? '';
const gitMatch = /^diff --git a\/(.+?) b\/(.+)$/.exec(header);
let oldPath = gitMatch?.[1] ?? '';
let newPath = gitMatch?.[2] ?? oldPath;
let status: DiffFileStatus = 'modified';
let isBinary = false;
let additions = 0;
let deletions = 0;
let renameFrom = '';
let renameTo = '';
for (const line of sectionLines) {
if (line.startsWith('new file mode')) status = 'added';
else if (line.startsWith('deleted file mode')) status = 'deleted';
else if (line.startsWith('rename from ')) {
renameFrom = line.slice('rename from '.length);
} else if (line.startsWith('rename to ')) {
renameTo = line.slice('rename to '.length);
} else if (
line.startsWith('Binary files') ||
line.startsWith('GIT binary patch')
) {
isBinary = true;
} else if (line.startsWith('--- ')) {
const value = line.slice(4).trim();
if (value !== '/dev/null') oldPath = stripABPrefix(value);
} else if (line.startsWith('+++ ')) {
const value = line.slice(4).trim();
if (value !== '/dev/null') newPath = stripABPrefix(value);
} else if (line.startsWith('+') && !line.startsWith('+++')) {
additions += 1;
} else if (line.startsWith('-') && !line.startsWith('---')) {
deletions += 1;
}
}
if (renameFrom || renameTo) {
status = 'renamed';
oldPath = renameFrom || oldPath;
newPath = renameTo || newPath;
}
const displayPath = status === 'deleted' ? oldPath : newPath;
return {
id: `${index}-${displayPath}`,
oldPath,
newPath,
displayPath,
status,
additions,
deletions,
isBinary,
hunkText: sectionLines.join('\n'),
};
});
};
/** Returns the single parsed file matching a path, if present in the diff. */
export const parseDiffFileForPath = (
diff: string | undefined,
filePath: string,
): ParsedDiffFile | undefined => {
const normalized = filePath.replace(/^\.\/+/, '');
return parseDiffFiles(diff).find(
(file) =>
file.displayPath === normalized ||
file.newPath === normalized ||
file.oldPath === normalized,
);
};
export const extractFileDiff = (diff: string | undefined, filePath: string) => { export const extractFileDiff = (diff: string | undefined, filePath: string) => {
if (!diff?.trim() || filePath === '.') return ''; if (!diff?.trim() || filePath === '.') return '';
const lines = diff.split('\n'); const lines = diff.split('\n');
@@ -1,25 +1,90 @@
'use client'; 'use client';
import dynamic from 'next/dynamic'; import { useMemo, useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { Button } from '@spoon/ui'; import { Button } from '@spoon/ui';
import { extractFileDiff } from './diff-utils'; import type { DiffMode } from './diff-file-view';
import type { DiffFileStatus, ParsedDiffFile } from './diff-utils';
import { DiffFileView, useDiffTheme } from './diff-file-view';
import { parseDiffFiles } from './diff-utils';
const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), { const statusBadge: Record<
ssr: false, DiffFileStatus,
}); { label: string; className: string }
> = {
added: { label: 'Added', className: 'bg-emerald-500/15 text-emerald-500' },
deleted: { label: 'Deleted', className: 'bg-red-500/15 text-red-500' },
modified: { label: 'Modified', className: 'bg-amber-500/15 text-amber-500' },
renamed: { label: 'Renamed', className: 'bg-sky-500/15 text-sky-500' },
};
const diffStats = (diff: string) => { const totals = (files: ParsedDiffFile[]) =>
const files = new Set<string>(); files.reduce(
let additions = 0; (acc, file) => ({
let removals = 0; additions: acc.additions + file.additions,
for (const line of diff.split('\n')) { deletions: acc.deletions + file.deletions,
if (line.startsWith('diff --git ')) files.add(line); }),
if (line.startsWith('+') && !line.startsWith('+++')) additions += 1; { additions: 0, deletions: 0 },
if (line.startsWith('-') && !line.startsWith('---')) removals += 1; );
}
return { files: files.size, additions, removals }; const FileCard = ({
file,
mode,
theme,
defaultOpen,
}: {
file: ParsedDiffFile;
mode: DiffMode;
theme: 'light' | 'dark';
defaultOpen: boolean;
}) => {
const [open, setOpen] = useState(defaultOpen);
const badge = statusBadge[file.status];
return (
<div className='border-border overflow-hidden rounded-md border'>
<button
type='button'
onClick={() => setOpen((value) => !value)}
className='bg-muted/40 hover:bg-muted/70 flex w-full items-center gap-2 px-3 py-2 text-left transition-colors'
>
{open ? (
<ChevronDown className='text-muted-foreground size-4 flex-none' />
) : (
<ChevronRight className='text-muted-foreground size-4 flex-none' />
)}
<span className='min-w-0 flex-1 truncate font-mono text-xs'>
{file.status === 'renamed' && file.oldPath !== file.newPath ? (
<>
<span className='text-muted-foreground'>{file.oldPath} </span>
{file.newPath}
</>
) : (
file.displayPath
)}
</span>
<span
className={`flex-none rounded px-1.5 py-0.5 text-[10px] font-medium ${badge.className}`}
>
{badge.label}
</span>
<span className='flex-none font-mono text-xs'>
{file.additions > 0 ? (
<span className='text-emerald-500'>+{file.additions}</span>
) : null}{' '}
{file.deletions > 0 ? (
<span className='text-red-500'>{file.deletions}</span>
) : null}
</span>
</button>
{open ? (
<div className='overflow-x-auto'>
<DiffFileView file={file} mode={mode} theme={theme} />
</div>
) : null}
</div>
);
}; };
export const DiffViewer = ({ export const DiffViewer = ({
@@ -33,25 +98,65 @@ export const DiffViewer = ({
onRefresh: () => Promise<void>; onRefresh: () => Promise<void>;
onClearFocusedPath?: () => void; onClearFocusedPath?: () => void;
}) => { }) => {
const focusedDiff = focusedPath ? extractFileDiff(diff, focusedPath) : ''; const [mode, setMode] = useState<DiffMode>('unified');
const visibleDiff = focusedPath ? focusedDiff : diff; const theme = useDiffTheme();
const stats = diffStats(visibleDiff);
const files = useMemo(() => parseDiffFiles(diff), [diff]);
const normalizedFocus = focusedPath?.replace(/^\.\/+/, '');
const visibleFiles = useMemo(
() =>
normalizedFocus
? files.filter(
(file) =>
file.displayPath === normalizedFocus ||
file.newPath === normalizedFocus ||
file.oldPath === normalizedFocus,
)
: files,
[files, normalizedFocus],
);
const stats = totals(visibleFiles);
return ( return (
<div className='flex h-full min-h-0 flex-col'> <div className='flex h-full min-h-0 flex-col'>
<div className='border-border flex h-12 items-center justify-between gap-3 border-b px-3'> <div className='border-border flex h-12 items-center justify-between gap-3 border-b px-3'>
<div className='min-w-0'> <div className='min-w-0'>
<p className='truncate text-sm font-medium'> <p className='truncate text-sm font-medium'>
{focusedPath ? `Diff viewer: ${focusedPath}` : 'Diff viewer'} {focusedPath ? `Diff: ${focusedPath}` : 'Diff viewer'}
</p> </p>
<p className='text-muted-foreground truncate text-xs'> <p className='text-muted-foreground truncate text-xs'>
{visibleDiff.trim() {visibleFiles.length > 0
? `${stats.files} files, +${stats.additions} -${stats.removals}` ? `${visibleFiles.length} ${visibleFiles.length === 1 ? 'file' : 'files'}, `
: focusedPath : ''}
? 'No diff for this file' <span className='text-emerald-500'>+{stats.additions}</span>{' '}
: 'Current git diff'} <span className='text-red-500'>{stats.deletions}</span>
</p> </p>
</div> </div>
<div className='flex flex-none items-center gap-2'> <div className='flex flex-none items-center gap-2'>
<div className='border-border flex items-center rounded-md border p-0.5'>
<button
type='button'
onClick={() => setMode('unified')}
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
mode === 'unified'
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Unified
</button>
<button
type='button'
onClick={() => setMode('split')}
className={`rounded px-2 py-1 text-xs font-medium transition-colors ${
mode === 'split'
? 'bg-muted text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
Split
</button>
</div>
{focusedPath ? ( {focusedPath ? (
<Button <Button
type='button' type='button'
@@ -67,22 +172,18 @@ export const DiffViewer = ({
</Button> </Button>
</div> </div>
</div> </div>
{visibleDiff.trim() ? ( {visibleFiles.length > 0 ? (
<MonacoEditor <div className='flex flex-1 flex-col gap-3 overflow-y-auto p-3'>
height='100%' {visibleFiles.map((file, index) => (
width='100%' <FileCard
language='diff' key={file.id}
theme='vs-dark' file={file}
value={visibleDiff} mode={mode}
options={{ theme={theme}
readOnly: true, defaultOpen={visibleFiles.length <= 10 || index < 5}
minimap: { enabled: false },
fontSize: 13,
scrollBeyondLastLine: false,
automaticLayout: true,
scrollbar: { alwaysConsumeMouseWheel: false },
}}
/> />
))}
</div>
) : ( ) : (
<div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'> <div className='text-muted-foreground flex flex-1 items-center justify-center text-sm'>
{focusedPath {focusedPath
@@ -0,0 +1,163 @@
export const SPOON_DARK = 'spoon-dark';
export const SPOON_LIGHT = 'spoon-light';
type ThemeRule = { token: string; foreground?: string; fontStyle?: string };
type ThemeData = {
base: 'vs' | 'vs-dark';
inherit: boolean;
rules: ThemeRule[];
colors: Record<string, string>;
};
type DiagnosticsDefaults = {
setDiagnosticsOptions: (options: {
noSemanticValidation?: boolean;
noSyntaxValidation?: boolean;
noSuggestionDiagnostics?: boolean;
}) => void;
};
// Minimal typed surface of the bits of the Monaco namespace we touch. Avoids
// depending on monaco-editor's full (and, under our eslint program, unresolved)
// type graph while keeping these calls fully type-checked.
export type MonacoLike = {
editor: {
defineTheme: (name: string, data: ThemeData) => void;
remeasureFonts: () => void;
};
languages: {
typescript: {
typescriptDefaults: DiagnosticsDefaults;
javascriptDefaults: DiagnosticsDefaults;
};
};
};
// Hex equivalents of the site's oklch design tokens (tools/tailwind/theme.css),
// so the editor matches the rest of the app. Brand accent is the teal --primary.
const dark = {
bg: '#080e14', // --background
surface: '#10171e', // --card
surfaceAlt: '#192028', // --muted
border: '#29313a', // --border
fg: '#eef3f5', // --foreground
fgDim: '#cdd6dc',
muted: '#93a1a9', // --muted-foreground
comment: '#6b7d88',
teal: '#1fb895', // --primary
mint: '#8fd6b4',
cyan: '#5fd0e0',
blue: '#6aa6ff',
amber: '#e3b341',
red: '#f3625d', // --destructive
};
const light = {
bg: '#f7fbfa', // --background
surface: '#ffffff', // --card
surfaceAlt: '#eaeff3', // --muted
border: '#d4dce2', // --border
fg: '#0d1218', // --foreground
fgDim: '#26323c',
muted: '#555f68', // --muted-foreground
comment: '#6b7680',
teal: '#007560', // --primary
mint: '#2f8f6e',
cyan: '#0f7d92',
blue: '#2f6bd8',
amber: '#9a6b00',
red: '#d73337', // --destructive
};
const hex = (value: string) => value.slice(1);
const themeData = (p: typeof dark, base: 'vs' | 'vs-dark'): ThemeData => ({
base,
inherit: true,
rules: [
{ token: '', foreground: hex(p.fg) },
{ token: 'comment', foreground: hex(p.comment), fontStyle: 'italic' },
{ token: 'keyword', foreground: hex(p.teal) },
{ token: 'keyword.control', foreground: hex(p.teal) },
{ token: 'storage', foreground: hex(p.teal) },
{ token: 'string', foreground: hex(p.mint) },
{ token: 'string.key.json', foreground: hex(p.cyan) },
{ token: 'string.value.json', foreground: hex(p.mint) },
{ token: 'number', foreground: hex(p.amber) },
{ token: 'constant', foreground: hex(p.amber) },
{ token: 'regexp', foreground: hex(p.amber) },
{ token: 'type', foreground: hex(p.cyan) },
{ token: 'type.identifier', foreground: hex(p.cyan) },
{ token: 'interface', foreground: hex(p.cyan) },
{ token: 'namespace', foreground: hex(p.cyan) },
{ token: 'function', foreground: hex(p.blue) },
{ token: 'variable', foreground: hex(p.fgDim) },
{ token: 'variable.parameter', foreground: hex(p.fgDim) },
{ token: 'property', foreground: hex(p.fgDim) },
{ token: 'operator', foreground: hex(p.muted) },
{ token: 'delimiter', foreground: hex(p.muted) },
{ token: 'tag', foreground: hex(p.teal) },
{ token: 'attribute.name', foreground: hex(p.amber) },
{ token: 'attribute.value', foreground: hex(p.mint) },
{ token: 'metatag', foreground: hex(p.teal) },
],
colors: {
'editor.background': p.bg,
'editor.foreground': p.fg,
'editorCursor.foreground': p.teal,
'editorLineNumber.foreground': p.border,
'editorLineNumber.activeForeground': p.muted,
'editor.lineHighlightBackground': p.surface,
'editor.selectionBackground': `${p.teal}33`,
'editor.inactiveSelectionBackground': `${p.teal}22`,
'editor.findMatchBackground': `${p.teal}55`,
'editor.findMatchHighlightBackground': `${p.teal}33`,
'editorWhitespace.foreground': p.border,
'editorIndentGuide.background1': p.surfaceAlt,
'editorIndentGuide.activeBackground1': p.border,
'editorGutter.background': p.bg,
'editorWidget.background': p.surface,
'editorWidget.border': p.border,
'editorHoverWidget.background': p.surface,
'editorHoverWidget.border': p.border,
'editorSuggestWidget.background': p.surface,
'editorSuggestWidget.border': p.border,
'editorSuggestWidget.selectedBackground': p.surfaceAlt,
'editorBracketMatch.background': `${p.teal}22`,
'editorBracketMatch.border': p.teal,
'editorError.foreground': p.red,
'scrollbarSlider.background': `${p.border}aa`,
'scrollbarSlider.hoverBackground': p.border,
'scrollbarSlider.activeBackground': p.muted,
},
});
let configured = false;
/**
* Defines the site-matched editor themes and quiets the in-browser TypeScript
* service. Monaco's TS worker has no access to the project's node_modules or the
* `~`/`@` path aliases, so its semantic diagnostics (e.g. "Cannot find module
* '~/server/auth'") are always false positives here. We keep real syntax errors
* and disable the unresolvable semantic noise. Runs once per page load.
*/
export const configureSpoonMonaco = (monaco: MonacoLike) => {
monaco.editor.defineTheme(SPOON_DARK, themeData(dark, 'vs-dark'));
monaco.editor.defineTheme(SPOON_LIGHT, themeData(light, 'vs'));
if (configured) return;
configured = true;
for (const defaults of [
monaco.languages.typescript.typescriptDefaults,
monaco.languages.typescript.javascriptDefaults,
]) {
defaults.setDiagnosticsOptions({
noSemanticValidation: true,
noSuggestionDiagnostics: true,
noSyntaxValidation: false,
});
}
};
/** Re-measures glyph widths once the web font finishes loading so they align. */
export const remeasureFontsWhenReady = (monaco: MonacoLike) => {
void document.fonts.ready.then(() => monaco.editor.remeasureFonts());
};
@@ -0,0 +1,245 @@
'use client';
import type { ITheme, Terminal } from '@xterm/xterm';
import { useEffect, useRef, useState } from 'react';
import { useTheme } from 'next-themes';
import { Button } from '@spoon/ui';
import '@xterm/xterm/css/xterm.css';
const TERMINAL_FONT =
"var(--font-victor-mono), 'Geist Mono', ui-monospace, monospace";
type Status = 'connecting' | 'connected' | 'closed' | 'error' | 'unconfigured';
const darkTheme: ITheme = {
background: '#080e14',
foreground: '#eef3f5',
cursor: '#1fb895',
cursorAccent: '#080e14',
selectionBackground: '#1fb89544',
black: '#10171e',
red: '#f3625d',
green: '#8fd6b4',
yellow: '#e3b341',
blue: '#6aa6ff',
magenta: '#b692e8',
cyan: '#5fd0e0',
white: '#cdd6dc',
brightBlack: '#93a1a9',
brightRed: '#f3625d',
brightGreen: '#8fd6b4',
brightYellow: '#e3b341',
brightBlue: '#6aa6ff',
brightMagenta: '#b692e8',
brightCyan: '#5fd0e0',
brightWhite: '#eef3f5',
};
const lightTheme: ITheme = {
background: '#f7fbfa',
foreground: '#0d1218',
cursor: '#007560',
cursorAccent: '#f7fbfa',
selectionBackground: '#00756033',
black: '#0d1218',
red: '#d73337',
green: '#2f8f6e',
yellow: '#9a6b00',
blue: '#2f6bd8',
magenta: '#7c4dd1',
cyan: '#0f7d92',
white: '#26323c',
brightBlack: '#555f68',
brightRed: '#d73337',
brightGreen: '#2f8f6e',
brightYellow: '#9a6b00',
brightBlue: '#2f6bd8',
brightMagenta: '#7c4dd1',
brightCyan: '#0f7d92',
brightWhite: '#0d1218',
};
export const WorkspaceTerminal = ({
jobId,
active,
}: {
jobId: string;
active: boolean;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const { resolvedTheme } = useTheme();
const themeIsLight = resolvedTheme === 'light';
const [status, setStatus] = useState<Status>('connecting');
const [errorText, setErrorText] = useState<string>();
const [reconnectKey, setReconnectKey] = useState(0);
// Update the live terminal's theme without tearing down the session.
useEffect(() => {
if (termRef.current) {
termRef.current.options.theme = themeIsLight ? lightTheme : darkTheme;
}
}, [themeIsLight]);
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
const abortController = new AbortController();
const signal = abortController.signal;
// Read through a function so TS doesn't narrow `aborted` to a constant after
// the first guard (it changes asynchronously, on cleanup).
const isAborted = () => signal.aborted;
let ws: WebSocket | undefined;
let resizeObserver: ResizeObserver | undefined;
const encoder = new TextEncoder();
const start = async () => {
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all(
[
import('@xterm/xterm'),
import('@xterm/addon-fit'),
import('@xterm/addon-web-links'),
],
);
if (isAborted()) return;
setStatus('connecting');
setErrorText(undefined);
let response: Response;
try {
response = await fetch(`/api/agent-jobs/${jobId}/terminal-token`, {
signal,
});
} catch {
if (!isAborted()) setStatus('error');
return;
}
if (isAborted()) return;
if (response.status === 503) {
setStatus('unconfigured');
return;
}
if (!response.ok) {
setStatus('error');
setErrorText(await response.text().catch(() => undefined));
return;
}
const { url } = (await response.json()) as { url: string };
const term = new Terminal({
fontFamily: TERMINAL_FONT,
fontSize: 13,
lineHeight: 1.2,
cursorBlink: true,
theme: themeIsLight ? lightTheme : darkTheme,
allowProposedApi: true,
scrollback: 5000,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon());
term.open(container);
fit.fit();
termRef.current = term;
const sendResize = () => {
if (ws?.readyState !== WebSocket.OPEN) return;
ws.send(
JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }),
);
};
ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
if (isAborted()) return;
setStatus('connected');
sendResize();
term.focus();
};
ws.onmessage = (event: MessageEvent<ArrayBuffer | string>) => {
if (typeof event.data === 'string') term.write(event.data);
else term.write(new Uint8Array(event.data));
};
ws.onclose = () => {
if (!isAborted()) setStatus('closed');
};
ws.onerror = () => {
if (!isAborted()) setStatus('error');
};
term.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) ws.send(encoder.encode(data));
});
term.onResize(() => sendResize());
resizeObserver = new ResizeObserver(() => {
try {
fit.fit();
} catch {
// ignore transient layout errors
}
});
resizeObserver.observe(container);
};
void start();
return () => {
abortController.abort();
resizeObserver?.disconnect();
ws?.close();
termRef.current?.dispose();
termRef.current = null;
};
// resolvedTheme intentionally excluded: handled by the theme effect above.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, jobId, reconnectKey]);
return (
<div className='relative flex h-full min-h-0 flex-col'>
<div className='border-border flex h-10 flex-none items-center justify-between gap-3 border-b px-3'>
<p className='text-muted-foreground text-xs'>
{status === 'connected'
? 'Connected · workspace shell'
: status === 'connecting'
? 'Connecting…'
: status === 'closed'
? 'Session ended'
: status === 'unconfigured'
? 'Terminal not configured'
: 'Connection error'}
</p>
{status === 'closed' || status === 'error' ? (
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setReconnectKey((key) => key + 1)}
>
Reconnect
</Button>
) : null}
</div>
{status === 'unconfigured' ? (
<div className='text-muted-foreground flex flex-1 items-center justify-center p-6 text-center text-sm'>
The terminal is not enabled on this deployment.
<br />
Set NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL to enable it.
</div>
) : (
<div className='min-h-0 flex-1 overflow-hidden bg-[#080e14] p-2'>
<div ref={containerRef} className='h-full w-full' />
{errorText ? (
<p className='text-destructive mt-2 px-1 text-xs break-all'>
{errorText}
</p>
) : null}
</div>
)}
</div>
);
};
+10
View File
@@ -22,6 +22,9 @@ export const env = createEnv({
SPOON_AGENT_WORKER_URL: z.url().default('http://localhost:3921'), SPOON_AGENT_WORKER_URL: z.url().default('http://localhost:3921'),
SPOON_AGENT_WORKER_INTERNAL_TOKEN: z.string().optional(), SPOON_AGENT_WORKER_INTERNAL_TOKEN: z.string().optional(),
SPOON_WORKER_TOKEN: z.string().optional(), SPOON_WORKER_TOKEN: z.string().optional(),
// Secret shared with the worker for signing short-lived terminal tokens.
// Falls back (in code) to the worker internal token.
SPOON_AGENT_TERMINAL_SECRET: z.string().optional(),
}, },
/** /**
@@ -36,6 +39,10 @@ export const env = createEnv({
NEXT_PUBLIC_SENTRY_URL: z.string(), NEXT_PUBLIC_SENTRY_URL: z.string(),
NEXT_PUBLIC_SENTRY_ORG: z.string(), NEXT_PUBLIC_SENTRY_ORG: z.string(),
NEXT_PUBLIC_SENTRY_PROJECT_NAME: z.string(), NEXT_PUBLIC_SENTRY_PROJECT_NAME: z.string(),
// Browser-facing WebSocket base URL of the agent worker, e.g.
// `wss://worker.spoon.gbrown.org` (prod) or `ws://localhost:3921` (dev).
// When unset, the workspace Terminal tab is disabled.
NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL: z.string().optional(),
}, },
/** /**
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away. * Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
@@ -59,6 +66,9 @@ export const env = createEnv({
SPOON_AGENT_WORKER_INTERNAL_TOKEN: SPOON_AGENT_WORKER_INTERNAL_TOKEN:
process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN, process.env.SPOON_AGENT_WORKER_INTERNAL_TOKEN,
SPOON_WORKER_TOKEN: process.env.SPOON_WORKER_TOKEN, SPOON_WORKER_TOKEN: process.env.SPOON_WORKER_TOKEN,
SPOON_AGENT_TERMINAL_SECRET: process.env.SPOON_AGENT_TERMINAL_SECRET,
NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL:
process.env.NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL, NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL, NEXT_PUBLIC_CONVEX_URL: process.env.NEXT_PUBLIC_CONVEX_URL,
NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL, NEXT_PUBLIC_PLAUSIBLE_URL: process.env.NEXT_PUBLIC_PLAUSIBLE_URL,
+21
View File
@@ -1,5 +1,6 @@
import 'server-only'; import 'server-only';
import { createHmac } from 'node:crypto';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { env } from '@/env'; import { env } from '@/env';
import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server'; import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server';
@@ -8,6 +9,26 @@ import { fetchQuery } from 'convex/nextjs';
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js'; import { api } from '@spoon/backend/convex/_generated/api.js';
const terminalSecret = () =>
env.SPOON_AGENT_TERMINAL_SECRET ??
env.SPOON_AGENT_WORKER_INTERNAL_TOKEN ??
env.SPOON_WORKER_TOKEN;
// Mints a short-lived, job-scoped terminal token + the worker WS URL. Returns
// null when the terminal feature is not configured. The 2-minute expiry is a
// connect window only; an established PTY session persists past it.
export const mintTerminalToken = (jobId: Id<'agentJobs'>) => {
const secret = terminalSecret();
const base = env.NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL;
if (!secret || !base) return null;
const expiresAt = Date.now() + 2 * 60 * 1000;
const payload = `${expiresAt}.${jobId}`;
const signature = createHmac('sha256', secret).update(payload).digest('hex');
const token = `${payload}.${signature}`;
const url = `${base.replace(/\/$/, '')}/jobs/${encodeURIComponent(jobId)}/terminal?token=${encodeURIComponent(token)}`;
return { url, expiresAt };
};
type RouteContext = { type RouteContext = {
params: Promise<{ jobId: string }> | { jobId: string }; params: Promise<{ jobId: string }> | { jobId: string };
}; };
+132 -7
View File
@@ -23,14 +23,18 @@
"@octokit/rest": "^22.0.1", "@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "latest", "@opencode-ai/sdk": "latest",
"convex": "catalog:convex", "convex": "catalog:convex",
"dockerode": "^4.0.7",
"execa": "latest", "execa": "latest",
"ws": "catalog:",
"zod": "catalog:", "zod": "catalog:",
}, },
"devDependencies": { "devDependencies": {
"@spoon/eslint-config": "workspace:*", "@spoon/eslint-config": "workspace:*",
"@spoon/prettier-config": "workspace:*", "@spoon/prettier-config": "workspace:*",
"@spoon/tsconfig": "workspace:*", "@spoon/tsconfig": "workspace:*",
"@types/dockerode": "^3.3.42",
"@types/node": "catalog:", "@types/node": "catalog:",
"@types/ws": "^8.18.1",
"eslint": "catalog:", "eslint": "catalog:",
"prettier": "catalog:", "prettier": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
@@ -97,16 +101,21 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@convex-dev/auth": "catalog:convex", "@convex-dev/auth": "catalog:convex",
"@git-diff-view/react": "^0.1.6",
"@monaco-editor/react": "latest", "@monaco-editor/react": "latest",
"@sentry/nextjs": "^10.46.0", "@sentry/nextjs": "^10.46.0",
"@spoon/backend": "workspace:*", "@spoon/backend": "workspace:*",
"@spoon/ui": "workspace:*", "@spoon/ui": "workspace:*",
"@t3-oss/env-nextjs": "^0.13.11", "@t3-oss/env-nextjs": "^0.13.11",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"convex": "catalog:convex", "convex": "catalog:convex",
"monaco-editor": "latest", "monaco-editor": "latest",
"monaco-vim": "latest", "monaco-vim": "latest",
"next": "^16.2.1", "next": "^16.2.1",
"next-plausible": "^3.12.5", "next-plausible": "^3.12.5",
"next-themes": "^0.4.6",
"react": "catalog:react19", "react": "catalog:react19",
"react-dom": "catalog:react19", "react-dom": "catalog:react19",
"require-in-the-middle": "^7.5.2", "require-in-the-middle": "^7.5.2",
@@ -537,6 +546,8 @@
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@balena/dockerignore": ["@balena/dockerignore@1.0.2", "", {}, "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="],
"@base-ui/react": ["@base-ui/react@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@base-ui/utils": "0.2.6", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA=="], "@base-ui/react": ["@base-ui/react@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@base-ui/utils": "0.2.6", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA=="],
"@base-ui/utils": ["@base-ui/utils@0.2.6", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw=="], "@base-ui/utils": ["@base-ui/utils@0.2.6", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw=="],
@@ -705,6 +716,16 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@git-diff-view/core": ["@git-diff-view/core@0.1.6", "", { "dependencies": { "@git-diff-view/lowlight": "^0.1.6", "fast-diff": "^1.3.0", "highlight.js": "^11.11.0", "lowlight": "^3.3.0" } }, "sha512-q2Ch8jURF6pL7VeNpOgHBRVY9gsGLXCOYpKXHG3BqpXe0kv6GNSUux8SmAYsDrakBzfgDClODxDtsM2rfiWpnA=="],
"@git-diff-view/lowlight": ["@git-diff-view/lowlight@0.1.6", "", { "dependencies": { "@types/hast": "^3.0.0", "highlight.js": "^11.11.0", "lowlight": "^3.3.0" } }, "sha512-YIsiAc2aWAePWaDNi3k8xI0Vs/ZItt5J6nrftTIFbMFN3GwDOsyJFm2L7o8XWKTJkV2yItaz28KUI9CWj0MVZA=="],
"@git-diff-view/react": ["@git-diff-view/react@0.1.6", "", { "dependencies": { "@git-diff-view/core": "^0.1.6", "@types/hast": "^3.0.0", "fast-diff": "^1.3.0", "highlight.js": "^11.11.0", "lowlight": "^3.3.0", "reactivity-store": "^0.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-koABBon5bNKh6/WnWSxggK9ojw+cvWAPnY2/ciOkwlR+8dm0h6A7Qa5kP2HFDxqYHwZ2imkGMcSLgXMOnWHRFA=="],
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
"@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="],
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="], "@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
@@ -831,6 +852,8 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
"@legendapp/list": ["@legendapp/list@2.0.19", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-zDWg8yg0smKxxk+M7gwAbZAnf5uczohPA+IjqLSkImz7+e9ytxeT0Mq35RBO9RTKODOXfV/aIgm1uqUHLBEdmg=="], "@legendapp/list": ["@legendapp/list@2.0.19", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-zDWg8yg0smKxxk+M7gwAbZAnf5uczohPA+IjqLSkImz7+e9ytxeT0Mq35RBO9RTKODOXfV/aIgm1uqUHLBEdmg=="],
"@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="], "@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="],
@@ -1041,6 +1064,24 @@
"@prisma/instrumentation": ["@prisma/instrumentation@7.4.2", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-r9JfchJF1Ae6yAxcaLu/V1TGqBhAuSDe3mRNOssBfx1rMzfZ4fdNvrgUBwyb/TNTGXFxlH9AZix5P257x07nrg=="], "@prisma/instrumentation": ["@prisma/instrumentation@7.4.2", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-r9JfchJF1Ae6yAxcaLu/V1TGqBhAuSDe3mRNOssBfx1rMzfZ4fdNvrgUBwyb/TNTGXFxlH9AZix5P257x07nrg=="],
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="],
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="],
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
@@ -1513,6 +1554,10 @@
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/docker-modem": ["@types/docker-modem@3.0.6", "", { "dependencies": { "@types/node": "*", "@types/ssh2": "*" } }, "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg=="],
"@types/dockerode": ["@types/dockerode@3.3.47", "", { "dependencies": { "@types/docker-modem": "*", "@types/node": "*", "@types/ssh2": "*" } }, "sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw=="],
"@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="],
"@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="], "@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="],
@@ -1525,6 +1570,8 @@
"@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="], "@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
"@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="], "@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="],
@@ -1547,12 +1594,16 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
"@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="],
"@types/tedious": ["@types/tedious@4.0.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw=="], "@types/tedious": ["@types/tedious@4.0.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
@@ -1605,6 +1656,10 @@
"@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], "@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="],
"@vue/reactivity": ["@vue/reactivity@3.5.38", "", { "dependencies": { "@vue/shared": "3.5.38" } }, "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ=="],
"@vue/shared": ["@vue/shared@3.5.38", "", {}, "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug=="],
"@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="], "@webassemblyjs/ast": ["@webassemblyjs/ast@1.14.1", "", { "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ=="],
"@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="], "@webassemblyjs/floating-point-hex-parser": ["@webassemblyjs/floating-point-hex-parser@1.13.2", "", {}, "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA=="],
@@ -1637,6 +1692,12 @@
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
"@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
"@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="],
"@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
"@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="], "@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="],
"@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="], "@xtuc/long": ["@xtuc/long@4.2.2", "", {}, "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="],
@@ -1701,6 +1762,8 @@
"asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="],
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
@@ -1751,6 +1814,8 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.10.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg=="],
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], "before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"better-opn": ["better-opn@3.0.2", "", { "dependencies": { "open": "^8.0.4" } }, "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ=="], "better-opn": ["better-opn@3.0.2", "", { "dependencies": { "open": "^8.0.4" } }, "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ=="],
@@ -1761,6 +1826,8 @@
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="], "bplist-creator": ["bplist-creator@0.1.0", "", { "dependencies": { "stream-buffers": "2.2.x" } }, "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg=="],
"bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="], "bplist-parser": ["bplist-parser@0.3.2", "", { "dependencies": { "big-integer": "1.6.x" } }, "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ=="],
@@ -1777,6 +1844,8 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
@@ -1799,7 +1868,7 @@
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
"chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="], "chrome-launcher": ["chrome-launcher@0.15.2", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0" }, "bin": { "print-chrome-path": "bin/print-chrome-path.js" } }, "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ=="],
@@ -1881,6 +1950,8 @@
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="], "cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
"cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="], "cross-fetch": ["cross-fetch@3.2.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -1965,6 +2036,12 @@
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"docker-modem": ["docker-modem@5.0.7", "", { "dependencies": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", "ssh2": "^1.15.0" } }, "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA=="],
"dockerode": ["dockerode@4.0.12", "", { "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", "@grpc/proto-loader": "^0.7.13", "docker-modem": "^5.0.7", "protobufjs": "^7.3.2", "tar-fs": "^2.1.4", "uuid": "^10.0.0" } }, "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw=="],
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
"dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
@@ -2003,6 +2080,8 @@
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"engine.io": ["engine.io@6.6.4", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1" } }, "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g=="], "engine.io": ["engine.io@6.6.4", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1" } }, "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g=="],
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
@@ -2157,6 +2236,8 @@
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
"fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], "fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
@@ -2205,6 +2286,8 @@
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
"fs-monkey": ["fs-monkey@1.1.0", "", {}, "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw=="], "fs-monkey": ["fs-monkey@1.1.0", "", {}, "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw=="],
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
@@ -2277,6 +2360,8 @@
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
"hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], "hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="],
@@ -2523,6 +2608,8 @@
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
@@ -2533,8 +2620,12 @@
"log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="],
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
"lucia": ["lucia@3.2.2", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0" } }, "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA=="], "lucia": ["lucia@3.2.2", "", { "dependencies": { "@oslojs/crypto": "^1.0.1", "@oslojs/encoding": "^1.1.0" } }, "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA=="],
@@ -2617,6 +2708,8 @@
"mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="],
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="],
"monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="], "monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="],
@@ -2635,6 +2728,8 @@
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nan": ["nan@2.27.0", "", {}, "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nativewind": ["nativewind@5.0.0-preview.2", "", { "dependencies": { "tailwindcss-safe-area": "^1.1.0" }, "peerDependencies": { "react-native-css": "^3.0.1", "tailwindcss": ">4.1.11" } }, "sha512-rTNrwFIwl/n2VH7KPvsZj/NdvKf+uGHF4NYtPamr5qG2eTYGT8B8VeyCPfYf/xUskpWOLJVqVEXaFO/vuIDEdw=="], "nativewind": ["nativewind@5.0.0-preview.2", "", { "dependencies": { "tailwindcss-safe-area": "^1.1.0" }, "peerDependencies": { "react-native-css": "^3.0.1", "tailwindcss": ">4.1.11" } }, "sha512-rTNrwFIwl/n2VH7KPvsZj/NdvKf+uGHF4NYtPamr5qG2eTYGT8B8VeyCPfYf/xUskpWOLJVqVEXaFO/vuIDEdw=="],
@@ -2805,8 +2900,12 @@
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qrcode-terminal": ["qrcode-terminal@0.11.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ=="], "qrcode-terminal": ["qrcode-terminal@0.11.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ=="],
@@ -2877,6 +2976,10 @@
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"reactivity-store": ["reactivity-store@0.4.0", "", { "dependencies": { "@vue/reactivity": "~3.5.30", "@vue/shared": "~3.5.30", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-uL9uoREOBg2o4zUa8vMU0AbvAOk0osPloizscmyZqMvJzcuuKX3ELFYYr1DX8gAcfvlhPduz4QuLZn1eChCu4Q=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"recharts": ["recharts@3.8.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="], "recharts": ["recharts@3.8.1", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg=="],
@@ -3031,10 +3134,14 @@
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"split-ca": ["split-ca@1.0.1", "", {}, "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="],
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="], "split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
"stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
@@ -3073,6 +3180,8 @@
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
@@ -3119,6 +3228,10 @@
"tar": ["tar@7.5.12", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw=="], "tar": ["tar@7.5.12", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw=="],
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="], "terminal-link": ["terminal-link@2.1.1", "", { "dependencies": { "ansi-escapes": "^4.2.1", "supports-hyperlinks": "^2.0.0" } }, "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ=="],
"terser": ["terser@5.44.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w=="], "terser": ["terser@5.44.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w=="],
@@ -3173,6 +3286,8 @@
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="], "type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="],
@@ -3231,9 +3346,11 @@
"usesend-js": ["usesend-js@1.6.3", "", { "dependencies": { "@react-email/render": "^1.0.6", "react": "^19.1.0" } }, "sha512-HKhW4F+RbAnp6izWxo2sjISmxhYQvxAjAsBFvdn0P25oVnZ8kXTMjvEqKyvkhgRrzXALu0N6NUyQjVOdOsjnoA=="], "usesend-js": ["usesend-js@1.6.3", "", { "dependencies": { "@react-email/render": "^1.0.6", "react": "^19.1.0" } }, "sha512-HKhW4F+RbAnp6izWxo2sjISmxhYQvxAjAsBFvdn0P25oVnZ8kXTMjvEqKyvkhgRrzXALu0N6NUyQjVOdOsjnoA=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
"uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], "uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
"validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="], "validate-npm-package-name": ["validate-npm-package-name@5.0.1", "", {}, "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ=="],
@@ -3297,7 +3414,7 @@
"write-file-atomic": ["write-file-atomic@4.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" } }, "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg=="], "write-file-atomic": ["write-file-atomic@4.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^3.0.7" } }, "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg=="],
"ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="], "xcode": ["xcode@3.0.1", "", { "dependencies": { "simple-plist": "^1.1.0", "uuid": "^7.0.3" } }, "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA=="],
@@ -3399,8 +3516,6 @@
"@expo/cli/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "@expo/cli/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"@expo/cli/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"@expo/config/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="], "@expo/config/@babel/code-frame": ["@babel/code-frame@7.10.4", "", { "dependencies": { "@babel/highlight": "^7.10.4" } }, "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg=="],
"@expo/config/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "@expo/config/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -3455,6 +3570,8 @@
"@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="], "@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="],
"@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
"@ianvs/prettier-plugin-sort-imports/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], "@ianvs/prettier-plugin-sort-imports/@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@ianvs/prettier-plugin-sort-imports/@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="], "@ianvs/prettier-plugin-sort-imports/@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
@@ -3707,6 +3824,8 @@
"@sentry/vercel-edge/@sentry/core": ["@sentry/core@10.46.0", "", {}, "sha512-N3fj4zqBQOhXliS1Ne9euqIKuciHCGOJfPGQLwBoW9DNz03jF+NB8+dUKtrJ79YLoftjVgf8nbgwtADK7NR+2Q=="], "@sentry/vercel-edge/@sentry/core": ["@sentry/core@10.46.0", "", {}, "sha512-N3fj4zqBQOhXliS1Ne9euqIKuciHCGOJfPGQLwBoW9DNz03jF+NB8+dUKtrJ79YLoftjVgf8nbgwtADK7NR+2Q=="],
"@sentry/webpack-plugin/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
"@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "@tailwindcss/node/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
@@ -3745,6 +3864,8 @@
"@types/pg/@types/node": ["@types/node@22.18.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A=="], "@types/pg/@types/node": ["@types/node@22.18.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A=="],
"@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@types/tedious/@types/node": ["@types/node@22.18.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A=="], "@types/tedious/@types/node": ["@types/node@22.18.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A=="],
"@types/ws/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], "@types/ws/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
@@ -3823,6 +3944,8 @@
"convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], "convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
"convex/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"dot-prop/type-fest": ["type-fest@5.5.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="], "dot-prop/type-fest": ["type-fest@5.5.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="],
@@ -3899,8 +4022,6 @@
"happy-dom/whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "happy-dom/whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
"happy-dom/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
@@ -4079,6 +4200,8 @@
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"tar/chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"terminal-link/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], "terminal-link/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
"terser/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "terser/acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -4427,6 +4550,8 @@
"@types/pg/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@types/pg/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@types/tedious/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@types/tedious/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"@types/ws/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@types/ws/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+2
View File
@@ -15,6 +15,7 @@ ARG NEXT_PUBLIC_SENTRY_DSN
ARG NEXT_PUBLIC_SENTRY_URL ARG NEXT_PUBLIC_SENTRY_URL
ARG NEXT_PUBLIC_SENTRY_ORG ARG NEXT_PUBLIC_SENTRY_ORG
ARG NEXT_PUBLIC_SENTRY_PROJECT_NAME ARG NEXT_PUBLIC_SENTRY_PROJECT_NAME
ARG NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
ENV SENTRY_DISABLE_AUTO_UPLOAD=$SENTRY_DISABLE_AUTO_UPLOAD ENV SENTRY_DISABLE_AUTO_UPLOAD=$SENTRY_DISABLE_AUTO_UPLOAD
@@ -25,6 +26,7 @@ ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN
ENV NEXT_PUBLIC_SENTRY_URL=$NEXT_PUBLIC_SENTRY_URL ENV NEXT_PUBLIC_SENTRY_URL=$NEXT_PUBLIC_SENTRY_URL
ENV NEXT_PUBLIC_SENTRY_ORG=$NEXT_PUBLIC_SENTRY_ORG ENV NEXT_PUBLIC_SENTRY_ORG=$NEXT_PUBLIC_SENTRY_ORG
ENV NEXT_PUBLIC_SENTRY_PROJECT_NAME=$NEXT_PUBLIC_SENTRY_PROJECT_NAME ENV NEXT_PUBLIC_SENTRY_PROJECT_NAME=$NEXT_PUBLIC_SENTRY_PROJECT_NAME
ENV NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL=$NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL
# Copy source code (node_modules excluded via .dockerignore) # Copy source code (node_modules excluded via .dockerignore)
COPY . . COPY . .
+6
View File
@@ -11,9 +11,15 @@ RUN apt-get update \
curl \ curl \
git \ git \
jq \ jq \
less \
locales \
neovim \
openssh-client \ openssh-client \
python3 \ python3 \
ripgrep \ ripgrep \
tmux \
unzip \
wget \
&& corepack enable \ && corepack enable \
&& corepack prepare pnpm@latest --activate \ && corepack prepare pnpm@latest --activate \
&& corepack prepare yarn@stable --activate \ && corepack prepare yarn@stable --activate \
+1
View File
@@ -17,6 +17,7 @@ services:
NEXT_PUBLIC_SENTRY_URL: ${NEXT_PUBLIC_SENTRY_URL} NEXT_PUBLIC_SENTRY_URL: ${NEXT_PUBLIC_SENTRY_URL}
NEXT_PUBLIC_SENTRY_ORG: ${NEXT_PUBLIC_SENTRY_ORG} NEXT_PUBLIC_SENTRY_ORG: ${NEXT_PUBLIC_SENTRY_ORG}
NEXT_PUBLIC_SENTRY_PROJECT_NAME: ${NEXT_PUBLIC_SENTRY_PROJECT_NAME} NEXT_PUBLIC_SENTRY_PROJECT_NAME: ${NEXT_PUBLIC_SENTRY_PROJECT_NAME}
NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL: ${NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL:-}
image: spoon-next:latest image: spoon-next:latest
#image: git.gbrown.org/gib/spoon-next:latest #image: git.gbrown.org/gib/spoon-next:latest
container_name: ${NEXT_CONTAINER_NAME} container_name: ${NEXT_CONTAINER_NAME}
+104
View File
@@ -0,0 +1,104 @@
# Workspace interactive terminal
A real shell inside the agent workspace, shown as the **Terminal** tab in the
workspace UI. It's an xterm.js front end bridged to a bash/tmux PTY running in a
persistent per-job container (the agent job image), mounting the same workspace
the editor and agent use.
## Architecture
```
browser (xterm.js)
│ 1. GET /api/agent-jobs/:id/terminal-token (Convex-auth'd, owner only)
│ → { url: "wss://worker…/jobs/:id/terminal?token=…", expiresAt }
│ 2. WebSocket wss://worker.<domain>/jobs/:id/terminal?token=…
nginx ── upgrade ──► spoon-agent-worker :3921
│ verifyTerminalToken(token, jobId, secret)
│ dockerode exec -t → bash/tmux PTY
spoon-agent-term-<jobId> (job image, mounts the workspace)
```
- The browser **never** holds the worker secret. The Next app (which has already
verified job ownership) mints a short-lived HMAC token; the worker verifies it.
- Frames: **binary** = stdin/stdout bytes; **text JSON** `{type:"resize",cols,rows}`
= resize. The token's 2-minute expiry is a _connect_ window; an established
session persists.
- The shell runs `tmux new-session -A -s spoon` (falls back to `bash -l`), so
reconnecting reattaches the same session. Idle containers are removed after
`SPOON_AGENT_TERMINAL_IDLE_MS` (default 30m).
## Configuration
| Where | Variable | Required? | Notes |
| -------- | --------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Next app | `NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL` | **Yes** | Browser-facing worker WS base, e.g. `wss://worker.spoon.gbrown.org` (prod) or `ws://localhost:3921` (dev). **Build-time** (`NEXT_PUBLIC`): for the Docker image it must be passed as a build arg (wired in `docker/Dockerfile` + `docker/compose.yml`, sourced from the build env file), not a runtime env. Unset → the Terminal tab shows "not configured". |
| Next app | `SPOON_AGENT_TERMINAL_SECRET` | No | HMAC secret for signing tokens. Falls back to `SPOON_AGENT_WORKER_INTERNAL_TOKEN`. |
| Worker | `SPOON_AGENT_TERMINAL_SECRET` | No | Must match the Next app's. Falls back to `SPOON_AGENT_WORKER_INTERNAL_TOKEN` (already shared), so by default **no new secret is needed**. |
| Worker | `SPOON_AGENT_TERMINAL_IMAGE` | No | Shell container image. Defaults to `SPOON_AGENT_JOB_IMAGE`. |
| Worker | `SPOON_AGENT_TERMINAL_IDLE_MS` | No | Idle-container reap delay (default `1800000`). |
Because the secret defaults to the already-shared worker token, the **only**
required step is setting `NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL` and exposing the
worker over nginx (prod).
## Exposing the worker (prod, nginx)
The worker and nginx are on the same `nginx-bridge` network, so nginx can reach
`spoon-agent-worker:3921` directly — no published port needed. Add a server block
that upgrades WebSockets:
```nginx
server {
listen 443 ssl;
server_name worker.spoon.gbrown.org;
# ssl_certificate ... ; ssl_certificate_key ... ;
location / {
proxy_pass http://spoon-agent-worker:3921;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s; # keep idle terminals open
proxy_send_timeout 86400s;
}
}
```
Then set on the Next app: `NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL=wss://worker.spoon.gbrown.org`.
> The worker's HTTP routes (`/jobs/:id/tree` etc.) require the internal bearer
> token, so exposing the worker host only usefully exposes the token-gated
> `/jobs/:id/terminal` upgrade. Still, restrict the server block to TLS.
## Dev testing (no nginx)
The dev worker runs on the host at `localhost:3921` (`bun dev:next:worker`), so
the browser can hit it directly:
```
NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL=ws://localhost:3921
```
Note: the terminal uses **dockerode** against the Docker socket. In dev with
Podman, point it at the Podman socket (run `podman system service` and set
`DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock`), or run the worker in
Docker mode. Prod (Docker socket mounted) works as-is.
## Security
- Owner-only: the token route uses Convex auth + `assertOwned`.
- Tokens are short-lived (2m connect window), job-scoped, HMAC-signed.
- A shell in the workspace can reach the network and the repo's git credentials.
This is intended for the single-user self-hosted deployment; do not expose the
worker domain without TLS, and keep the deployment single-tenant.
## Tools in the shell
The job image ships `bash`, `tmux`, `neovim`, `git`, `ripgrep`, `jq`, `python3`,
`node`, `bun`, `pnpm`, `yarn`, `curl`/`wget`, `unzip`. Bring your own dotfiles by
cloning them in-session (e.g. `git clone <dotfiles> ~/.config/...`); persistent
auto-cloning of a dotfiles repo is a planned follow-up.
+5
View File
@@ -46,6 +46,11 @@ services:
- SPOON_AGENT_WORKER_URL=${SPOON_AGENT_WORKER_URL:-http://spoon-agent-worker:3921} - SPOON_AGENT_WORKER_URL=${SPOON_AGENT_WORKER_URL:-http://spoon-agent-worker:3921}
- SPOON_AGENT_WORKER_INTERNAL_TOKEN=${SPOON_AGENT_WORKER_INTERNAL_TOKEN} - SPOON_AGENT_WORKER_INTERNAL_TOKEN=${SPOON_AGENT_WORKER_INTERNAL_TOKEN}
- SPOON_WORKER_TOKEN=${SPOON_WORKER_TOKEN} - SPOON_WORKER_TOKEN=${SPOON_WORKER_TOKEN}
# NOTE: the Terminal tab needs NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL, which is
# a NEXT_PUBLIC (build-time) var — it must be baked into the spoon-next image
# at build (via the build env file / CI), NOT set as a runtime env here. Also
# requires nginx to proxy worker.<domain> → spoon-agent-worker:3921 with WS
# upgrade. See docs/agent-terminal.md.
depends_on: ['spoon-backend', 'spoon-postgres'] depends_on: ['spoon-backend', 'spoon-postgres']
labels: ['com.centurylinklabs.watchtower.enable=true'] labels: ['com.centurylinklabs.watchtower.enable=true']
tty: true tty: true
+4
View File
@@ -119,6 +119,10 @@
"eslint --flag unstable_native_nodejs_ts_config --fix --no-warn-ignored --config apps/expo/eslint.config.mts", "eslint --flag unstable_native_nodejs_ts_config --fix --no-warn-ignored --config apps/expo/eslint.config.mts",
"prettier --write" "prettier --write"
], ],
"apps/agent-worker/**/*.{ts,tsx}": [
"eslint --flag unstable_native_nodejs_ts_config --fix --no-warn-ignored --config apps/expo/eslint.config.mts",
"prettier --write"
],
"packages/backend/**/*.{ts,tsx}": [ "packages/backend/**/*.{ts,tsx}": [
"eslint --flag unstable_native_nodejs_ts_config --fix --no-warn-ignored --config packages/backend/eslint.config.ts", "eslint --flag unstable_native_nodejs_ts_config --fix --no-warn-ignored --config packages/backend/eslint.config.ts",
"prettier --write" "prettier --write"
+3
View File
@@ -37,6 +37,9 @@
"SPOON_WORKER_TOKEN", "SPOON_WORKER_TOKEN",
"SPOON_AGENT_WORKER_ID", "SPOON_AGENT_WORKER_ID",
"SPOON_AGENT_JOB_IMAGE", "SPOON_AGENT_JOB_IMAGE",
"SPOON_AGENT_TERMINAL_IMAGE",
"SPOON_AGENT_TERMINAL_SECRET",
"SPOON_AGENT_TERMINAL_IDLE_MS",
"SPOON_AGENT_RUNTIME", "SPOON_AGENT_RUNTIME",
"SPOON_AGENT_CONTAINER_RUNTIME", "SPOON_AGENT_CONTAINER_RUNTIME",
"SPOON_AGENT_CONTAINER_VOLUME_OPTIONS", "SPOON_AGENT_CONTAINER_VOLUME_OPTIONS",