mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 07:04:48 +00:00
feat: add process management support (#207)
* feat: improve inspector UI for processes and fix PTY terminal
- Simplify ProcessRunTab layout: compact form with collapsible Advanced section for timeout/maxOutputBytes
- Rewrite ProcessesTab: collapsible create form, lightweight list items with status dots, clean detail panel with tabs
- Extract error details: use problem.detail instead of generic "Stream Error" title for better error messages
- Fix GhosttyTerminal binary frame parsing: handle server's binary ArrayBuffer control frames (ready/exit/error)
- Enable WebSocket proxying in Vite dev server with ws: true
- Set TERM=xterm-256color default for TTY processes so tools like tmux, vim, htop work out of the box
- Remove orange gradient background from terminal container for cleaner look
- Remove orange left border from selected process list items
- Update inspector CSS with new process/terminal styles
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
* fix: address review issues and add processes documentation
- Fix unstable onExit callback in ProcessesTab (useCallback)
- Fix SSE follow stream race condition (subscribe before history read)
- Update inspector.mdx with new process management features
- Change observability icon to avoid conflict with processes
- Add docs/processes.mdx covering the full process management API
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: simplify processes doc — rename sections, remove low-level protocol
- Rename "Interactive terminals" to "Terminals" with "Connect to a terminal" sub-heading
- Add TTY process creation step at top of Terminals section
- Remove low-level WebSocket protocol table and raw WebSocket example
- Keep browser terminal emulator reference with Ghostty link
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: update GhosttyTerminal permalink to latest commit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: use main branch permalink for GhosttyTerminal reference
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: refine process API — WebSocket binary protocol, SDK terminal session, updated tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: update GhosttyTerminal permalink to 636eefb
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* inspector: use websocket terminal API
* sdk: restore high-level terminal session
* docs: update inspector terminal permalink
* inspector: update run once placeholder
* Fix lazy install v1 API test fixture
* Add reusable React terminal component
* Fix terminal WebSocket ready state checks
---------
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e7656d78f0
commit
febe8601f6
28 changed files with 2098 additions and 83 deletions
|
|
@ -109,6 +109,7 @@
|
||||||
- `docs/cli.mdx`
|
- `docs/cli.mdx`
|
||||||
- `docs/quickstart.mdx`
|
- `docs/quickstart.mdx`
|
||||||
- `docs/sdk-overview.mdx`
|
- `docs/sdk-overview.mdx`
|
||||||
|
- `docs/react-components.mdx`
|
||||||
- `docs/session-persistence.mdx`
|
- `docs/session-persistence.mdx`
|
||||||
- `docs/deploy/local.mdx`
|
- `docs/deploy/local.mdx`
|
||||||
- `docs/deploy/cloudflare.mdx`
|
- `docs/deploy/cloudflare.mdx`
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@
|
||||||
"pages": [
|
"pages": [
|
||||||
"quickstart",
|
"quickstart",
|
||||||
"sdk-overview",
|
"sdk-overview",
|
||||||
|
"react-components",
|
||||||
{
|
{
|
||||||
"group": "Deploy",
|
"group": "Deploy",
|
||||||
"icon": "server",
|
"icon": "server",
|
||||||
|
|
@ -79,7 +80,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"group": "System",
|
"group": "System",
|
||||||
"pages": ["file-system"]
|
"pages": ["file-system", "processes"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"group": "Orchestration",
|
"group": "Orchestration",
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,18 @@ console.log(url);
|
||||||
- Event JSON inspector
|
- Event JSON inspector
|
||||||
- Prompt testing
|
- Prompt testing
|
||||||
- Request/response debugging
|
- Request/response debugging
|
||||||
|
- Process management (create, stop, kill, delete, view logs)
|
||||||
|
- Interactive PTY terminal for tty processes
|
||||||
|
- One-shot command execution
|
||||||
|
|
||||||
## When to use
|
## When to use
|
||||||
|
|
||||||
- Development: validate session behavior quickly
|
- Development: validate session behavior quickly
|
||||||
- Debugging: inspect raw event payloads
|
- Debugging: inspect raw event payloads
|
||||||
- Integration work: compare UI behavior with SDK/API calls
|
- Integration work: compare UI behavior with SDK/API calls
|
||||||
|
|
||||||
|
## Process terminal
|
||||||
|
|
||||||
|
The Inspector includes an embedded Ghostty-based terminal for interactive tty
|
||||||
|
processes. The UI uses the SDK's high-level `connectProcessTerminal(...)`
|
||||||
|
wrapper via the shared `@sandbox-agent/react` `ProcessTerminal` component.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
title: "Observability"
|
title: "Observability"
|
||||||
description: "Track session activity with OpenTelemetry."
|
description: "Track session activity with OpenTelemetry."
|
||||||
icon: "terminal"
|
icon: "chart-line"
|
||||||
---
|
---
|
||||||
|
|
||||||
Use OpenTelemetry to instrument session traffic, then ship telemetry to your collector/backend.
|
Use OpenTelemetry to instrument session traffic, then ship telemetry to your collector/backend.
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
"license": {
|
"license": {
|
||||||
"name": "Apache-2.0"
|
"name": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"version": "0.2.1"
|
"version": "0.2.2"
|
||||||
},
|
},
|
||||||
"servers": [
|
"servers": [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
258
docs/processes.mdx
Normal file
258
docs/processes.mdx
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
---
|
||||||
|
title: "Processes"
|
||||||
|
description: "Run commands and manage long-lived processes inside the sandbox."
|
||||||
|
sidebarTitle: "Processes"
|
||||||
|
icon: "terminal"
|
||||||
|
---
|
||||||
|
|
||||||
|
The process API supports:
|
||||||
|
|
||||||
|
- **One-shot execution** — run a command to completion and capture stdout, stderr, and exit code
|
||||||
|
- **Managed processes** — spawn, list, stop, kill, and delete long-lived processes
|
||||||
|
- **Log streaming** — fetch buffered logs or follow live output via SSE
|
||||||
|
- **Terminals** — full PTY support with bidirectional WebSocket I/O
|
||||||
|
- **Configurable limits** — control concurrency, timeouts, and buffer sizes per runtime
|
||||||
|
|
||||||
|
## Run a command
|
||||||
|
|
||||||
|
Execute a command to completion and get its output.
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
import { SandboxAgent } from "sandbox-agent";
|
||||||
|
|
||||||
|
const sdk = await SandboxAgent.connect({
|
||||||
|
baseUrl: "http://127.0.0.1:2468",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await sdk.runProcess({
|
||||||
|
command: "ls",
|
||||||
|
args: ["-la", "/workspace"],
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(result.exitCode); // 0
|
||||||
|
console.log(result.stdout);
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"ls","args":["-la","/workspace"]}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
You can set a timeout and cap output size:
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
const result = await sdk.runProcess({
|
||||||
|
command: "make",
|
||||||
|
args: ["build"],
|
||||||
|
timeoutMs: 60000,
|
||||||
|
maxOutputBytes: 1048576,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.timedOut) {
|
||||||
|
console.log("Build timed out");
|
||||||
|
}
|
||||||
|
if (result.stdoutTruncated) {
|
||||||
|
console.log("Output was truncated");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/run" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"make","args":["build"],"timeoutMs":60000,"maxOutputBytes":1048576}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
## Managed processes
|
||||||
|
|
||||||
|
Create a long-lived process that you can interact with, monitor, and stop later.
|
||||||
|
|
||||||
|
### Create
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
const proc = await sdk.createProcess({
|
||||||
|
command: "node",
|
||||||
|
args: ["server.js"],
|
||||||
|
cwd: "/workspace",
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(proc.id, proc.pid); // proc_1, 12345
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"node","args":["server.js"],"cwd":"/workspace"}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### List and get
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
const { processes } = await sdk.listProcesses();
|
||||||
|
|
||||||
|
for (const p of processes) {
|
||||||
|
console.log(p.id, p.command, p.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = await sdk.getProcess("proc_1");
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl "http://127.0.0.1:2468/v1/processes"
|
||||||
|
|
||||||
|
curl "http://127.0.0.1:2468/v1/processes/proc_1"
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### Stop, kill, and delete
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
// SIGTERM with optional wait
|
||||||
|
await sdk.stopProcess("proc_1", { waitMs: 5000 });
|
||||||
|
|
||||||
|
// SIGKILL
|
||||||
|
await sdk.killProcess("proc_1", { waitMs: 1000 });
|
||||||
|
|
||||||
|
// Remove exited process record
|
||||||
|
await sdk.deleteProcess("proc_1");
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/proc_1/stop?waitMs=5000"
|
||||||
|
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/proc_1/kill?waitMs=1000"
|
||||||
|
|
||||||
|
curl -X DELETE "http://127.0.0.1:2468/v1/processes/proc_1"
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
### Fetch buffered logs
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
const logs = await sdk.getProcessLogs("proc_1", {
|
||||||
|
tail: 50,
|
||||||
|
stream: "combined",
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const entry of logs.entries) {
|
||||||
|
console.log(entry.stream, atob(entry.data));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl "http://127.0.0.1:2468/v1/processes/proc_1/logs?tail=50&stream=combined"
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### Follow logs via SSE
|
||||||
|
|
||||||
|
Stream log entries in real time. The subscription replays buffered entries first, then streams new output as it arrives.
|
||||||
|
|
||||||
|
```ts TypeScript
|
||||||
|
const sub = await sdk.followProcessLogs("proc_1", (entry) => {
|
||||||
|
console.log(entry.stream, atob(entry.data));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Later, stop following
|
||||||
|
sub.close();
|
||||||
|
await sub.closed;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Terminals
|
||||||
|
|
||||||
|
Create a process with `tty: true` to allocate a pseudo-terminal, then connect via WebSocket for full bidirectional I/O.
|
||||||
|
|
||||||
|
```ts TypeScript
|
||||||
|
const proc = await sdk.createProcess({
|
||||||
|
command: "bash",
|
||||||
|
tty: true,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Write input
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
await sdk.sendProcessInput("proc_1", {
|
||||||
|
data: "echo hello\n",
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/proc_1/input" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"data":"echo hello\n","encoding":"utf8"}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
|
|
||||||
|
### Connect to a terminal
|
||||||
|
|
||||||
|
Use `ProcessTerminalSession` unless you need direct frame access.
|
||||||
|
|
||||||
|
```ts TypeScript
|
||||||
|
const terminal = sdk.connectProcessTerminal("proc_1");
|
||||||
|
|
||||||
|
terminal.onReady(() => {
|
||||||
|
terminal.resize({ cols: 120, rows: 40 });
|
||||||
|
terminal.sendInput("ls\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
terminal.onData((bytes) => {
|
||||||
|
process.stdout.write(new TextDecoder().decode(bytes));
|
||||||
|
});
|
||||||
|
|
||||||
|
terminal.onExit((status) => {
|
||||||
|
console.log("exit:", status.exitCode);
|
||||||
|
});
|
||||||
|
|
||||||
|
terminal.onError((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : error.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
terminal.onClose(() => {
|
||||||
|
console.log("terminal closed");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Since the browser WebSocket API cannot send custom headers, the endpoint accepts an `access_token` query parameter for authentication. The SDK handles this automatically.
|
||||||
|
|
||||||
|
### Browser terminal emulators
|
||||||
|
|
||||||
|
The terminal session works with any browser terminal emulator like ghostty-web or xterm.js. For a drop-in React terminal, see [React Components](/react-components).
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Adjust runtime limits like max concurrent processes, timeouts, and buffer sizes.
|
||||||
|
|
||||||
|
<CodeGroup>
|
||||||
|
```ts TypeScript
|
||||||
|
const config = await sdk.getProcessConfig();
|
||||||
|
console.log(config);
|
||||||
|
|
||||||
|
await sdk.setProcessConfig({
|
||||||
|
...config,
|
||||||
|
maxConcurrentProcesses: 32,
|
||||||
|
defaultRunTimeoutMs: 60000,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash cURL
|
||||||
|
curl "http://127.0.0.1:2468/v1/processes/config"
|
||||||
|
|
||||||
|
curl -X POST "http://127.0.0.1:2468/v1/processes/config" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"maxConcurrentProcesses":32,"defaultRunTimeoutMs":60000,"maxRunTimeoutMs":300000,"maxOutputBytes":1048576,"maxLogBytesPerProcess":10485760,"maxInputBytesPerRequest":65536}'
|
||||||
|
```
|
||||||
|
</CodeGroup>
|
||||||
103
docs/react-components.mdx
Normal file
103
docs/react-components.mdx
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
---
|
||||||
|
title: "React Components"
|
||||||
|
description: "Drop-in React components for Sandbox Agent frontends."
|
||||||
|
icon: "react"
|
||||||
|
---
|
||||||
|
|
||||||
|
`@sandbox-agent/react` exposes small React components built on top of the `sandbox-agent` SDK.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @sandbox-agent/react@0.2.x
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full example
|
||||||
|
|
||||||
|
This example connects to a running Sandbox Agent server, starts a tty shell, renders `ProcessTerminal`, and cleans up the process when the component unmounts.
|
||||||
|
|
||||||
|
```tsx TerminalPane.tsx expandable highlight={5,32-36,71}
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { SandboxAgent } from "sandbox-agent";
|
||||||
|
import { ProcessTerminal } from "@sandbox-agent/react";
|
||||||
|
|
||||||
|
export default function TerminalPane() {
|
||||||
|
const [client, setClient] = useState<SandboxAgent | null>(null);
|
||||||
|
const [processId, setProcessId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let sdk: SandboxAgent | null = null;
|
||||||
|
let createdProcessId: string | null = null;
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
if (!sdk || !createdProcessId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sdk.killProcess(createdProcessId, { waitMs: 1_000 }).catch(() => {});
|
||||||
|
await sdk.deleteProcess(createdProcessId).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = async () => {
|
||||||
|
try {
|
||||||
|
sdk = await SandboxAgent.connect({
|
||||||
|
baseUrl: "http://127.0.0.1:2468",
|
||||||
|
});
|
||||||
|
|
||||||
|
const process = await sdk.createProcess({
|
||||||
|
command: "sh",
|
||||||
|
interactive: true,
|
||||||
|
tty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
createdProcessId = process.id;
|
||||||
|
await cleanup();
|
||||||
|
await sdk.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createdProcessId = process.id;
|
||||||
|
setClient(sdk);
|
||||||
|
setProcessId(process.id);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Failed to start terminal.";
|
||||||
|
setError(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void start();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
void cleanup();
|
||||||
|
void sdk?.dispose();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div>{error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client || !processId) {
|
||||||
|
return <div>Starting terminal...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <ProcessTerminal client={client} processId={processId} height={480} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component
|
||||||
|
|
||||||
|
`ProcessTerminal` attaches to a running tty process.
|
||||||
|
|
||||||
|
- `client`: a `SandboxAgent` client
|
||||||
|
- `processId`: the process to attach to
|
||||||
|
- `height`, `style`, `terminalStyle`: optional layout overrides
|
||||||
|
- `onExit`, `onError`: optional lifecycle callbacks
|
||||||
|
|
||||||
|
See [Processes](/processes) for the lower-level terminal APIs.
|
||||||
|
|
@ -29,6 +29,12 @@ The TypeScript SDK is centered on `sandbox-agent` and its `SandboxAgent` class.
|
||||||
npm install @sandbox-agent/persist-indexeddb@0.2.x @sandbox-agent/persist-sqlite@0.2.x @sandbox-agent/persist-postgres@0.2.x
|
npm install @sandbox-agent/persist-indexeddb@0.2.x @sandbox-agent/persist-sqlite@0.2.x @sandbox-agent/persist-postgres@0.2.x
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Optional React components
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @sandbox-agent/react@0.2.x
|
||||||
|
```
|
||||||
|
|
||||||
## Create a client
|
## Create a client
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|
@ -206,4 +212,3 @@ Parameters:
|
||||||
- `fetch` (optional): Custom fetch implementation used by SDK HTTP and ACP calls
|
- `fetch` (optional): Custom fetch implementation used by SDK HTTP and ACP calls
|
||||||
- `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and ACP session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait
|
- `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and ACP session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait
|
||||||
- `signal` (optional): aborts the startup `/v1/health` wait used by `connect()`
|
- `signal` (optional): aborts the startup `/v1/health` wait used by `connect()`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,4 @@
|
||||||
# Frontend Instructions
|
# Frontend Instructions
|
||||||
|
|
||||||
## Inspector Architecture
|
- When the user asks for UI changes, capture screenshots of the updated UI after implementation and verification.
|
||||||
|
- At the end, offer to open those screenshots for the user and provide absolute filesystem paths to the screenshot files.
|
||||||
- Inspector source is `frontend/packages/inspector/`.
|
|
||||||
- `/ui/` must use ACP over HTTP (`/v2/rpc`) for session/prompt traffic.
|
|
||||||
- Primary flow:
|
|
||||||
- `initialize`
|
|
||||||
- `session/new`
|
|
||||||
- `session/prompt`
|
|
||||||
- `session/update` over SSE
|
|
||||||
- Keep backend/protocol changes in client bindings; avoid unnecessary full UI rewrites.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Run inspector checks after transport or chat-flow changes:
|
|
||||||
```bash
|
|
||||||
pnpm --filter @sandbox-agent/inspector test
|
|
||||||
pnpm --filter @sandbox-agent/inspector test:agent-browser
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docs Sync
|
|
||||||
|
|
||||||
- Update `docs/inspector.mdx` when `/ui/` behavior changes.
|
|
||||||
- Update `docs/sdks/typescript.mdx` when inspector SDK bindings or ACP transport behavior changes.
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2648,6 +2648,350 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Process form buttons ── */
|
||||||
|
.process-run-form .button.primary,
|
||||||
|
.process-create-form .button.primary {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-detail > .button {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Run Once tab ── */
|
||||||
|
.process-run-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-run-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-run-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-run-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-run-field-grow {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-run-field .setup-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-run-field textarea.setup-input {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-advanced-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 0;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-advanced-toggle:hover {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-run-result {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-run-result-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-run-output {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-run-output-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-run-output-section + .process-run-output-section {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-run-output-label {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
color: var(--muted);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-run-output-section .process-log-block {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Processes tab ── */
|
||||||
|
.processes-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processes-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processes-section-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 0;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processes-section-toggle:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.processes-section-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.processes-list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-create-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-checkbox {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-checkbox input {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Process list items */
|
||||||
|
.process-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-list-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-list-item:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-list-item.selected {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-list-item-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-status-dot.running {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-status-dot.exited {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-list-item-cmd {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-list-item-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
padding-left: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-list-item-id {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-list-item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding-left: 14px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-list-item-actions .button {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Process detail panel */
|
||||||
|
.process-detail {
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-detail-cmd {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-detail-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 14px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-detail-logs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-detail-logs-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-detail-logs-header .button {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-terminal-empty {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px dashed rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Log block (shared) */
|
||||||
|
.process-log-block {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 80px;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(9, 9, 11, 0.95);
|
||||||
|
color: #e4e4e7;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.pill {
|
.pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -3026,6 +3370,26 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.process-run-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-run-output {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-run-output-section + .process-run-output-section {
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.process-terminal-meta {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Scrollbar - match landing page */
|
/* Scrollbar - match landing page */
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,13 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && vite build",
|
"build": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && pnpm --filter @sandbox-agent/react build && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && tsc --noEmit",
|
"typecheck": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && pnpm --filter @sandbox-agent/react build && tsc --noEmit",
|
||||||
"test": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && vitest run"
|
"test": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && pnpm --filter @sandbox-agent/react build && vitest run"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@sandbox-agent/react": "workspace:*",
|
||||||
"sandbox-agent": "workspace:*",
|
"sandbox-agent": "workspace:*",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
import { ChevronLeft, ChevronRight, Cloud, PlayCircle, Server, Terminal, Wrench } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Cloud, Play, PlayCircle, Server, Terminal, Wrench } from "lucide-react";
|
||||||
import type { AgentInfo, SandboxAgent, SessionEvent } from "sandbox-agent";
|
import type { AgentInfo, SandboxAgent, SessionEvent } from "sandbox-agent";
|
||||||
|
|
||||||
type AgentModeInfo = { id: string; name: string; description: string };
|
type AgentModeInfo = { id: string; name: string; description: string };
|
||||||
import AgentsTab from "./AgentsTab";
|
import AgentsTab from "./AgentsTab";
|
||||||
import EventsTab from "./EventsTab";
|
import EventsTab from "./EventsTab";
|
||||||
import McpTab from "./McpTab";
|
import McpTab from "./McpTab";
|
||||||
|
import ProcessesTab from "./ProcessesTab";
|
||||||
|
import ProcessRunTab from "./ProcessRunTab";
|
||||||
import SkillsTab from "./SkillsTab";
|
import SkillsTab from "./SkillsTab";
|
||||||
import RequestLogTab from "./RequestLogTab";
|
import RequestLogTab from "./RequestLogTab";
|
||||||
import type { RequestLog } from "../../types/requestLog";
|
import type { RequestLog } from "../../types/requestLog";
|
||||||
|
|
||||||
export type DebugTab = "log" | "events" | "agents" | "mcp" | "skills";
|
export type DebugTab = "log" | "events" | "agents" | "mcp" | "skills" | "processes" | "run-process";
|
||||||
|
|
||||||
const DebugPanel = ({
|
const DebugPanel = ({
|
||||||
debugTab,
|
debugTab,
|
||||||
|
|
@ -81,6 +83,14 @@ const DebugPanel = ({
|
||||||
<Server className="button-icon" style={{ marginRight: 4, width: 12, height: 12 }} />
|
<Server className="button-icon" style={{ marginRight: 4, width: 12, height: 12 }} />
|
||||||
MCP
|
MCP
|
||||||
</button>
|
</button>
|
||||||
|
<button className={`debug-tab ${debugTab === "processes" ? "active" : ""}`} onClick={() => onDebugTabChange("processes")}>
|
||||||
|
<Terminal className="button-icon" style={{ marginRight: 4, width: 12, height: 12 }} />
|
||||||
|
Processes
|
||||||
|
</button>
|
||||||
|
<button className={`debug-tab ${debugTab === "run-process" ? "active" : ""}`} onClick={() => onDebugTabChange("run-process")}>
|
||||||
|
<Play className="button-icon" style={{ marginRight: 4, width: 12, height: 12 }} />
|
||||||
|
Run Once
|
||||||
|
</button>
|
||||||
<button className={`debug-tab ${debugTab === "skills" ? "active" : ""}`} onClick={() => onDebugTabChange("skills")}>
|
<button className={`debug-tab ${debugTab === "skills" ? "active" : ""}`} onClick={() => onDebugTabChange("skills")}>
|
||||||
<Wrench className="button-icon" style={{ marginRight: 4, width: 12, height: 12 }} />
|
<Wrench className="button-icon" style={{ marginRight: 4, width: 12, height: 12 }} />
|
||||||
Skills
|
Skills
|
||||||
|
|
@ -122,6 +132,14 @@ const DebugPanel = ({
|
||||||
<McpTab getClient={getClient} />
|
<McpTab getClient={getClient} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{debugTab === "processes" && (
|
||||||
|
<ProcessesTab getClient={getClient} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{debugTab === "run-process" && (
|
||||||
|
<ProcessRunTab getClient={getClient} />
|
||||||
|
)}
|
||||||
|
|
||||||
{debugTab === "skills" && (
|
{debugTab === "skills" && (
|
||||||
<SkillsTab getClient={getClient} />
|
<SkillsTab getClient={getClient} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
import { ChevronDown, ChevronRight, Loader2, Play } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { SandboxAgentError } from "sandbox-agent";
|
||||||
|
import type { ProcessRunResponse, SandboxAgent } from "sandbox-agent";
|
||||||
|
|
||||||
|
const parseArgs = (value: string): string[] => value.split("\n").map((part) => part.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
const ProcessRunTab = ({
|
||||||
|
getClient,
|
||||||
|
}: {
|
||||||
|
getClient: () => SandboxAgent;
|
||||||
|
}) => {
|
||||||
|
const [command, setCommand] = useState("");
|
||||||
|
const [argsText, setArgsText] = useState("");
|
||||||
|
const [cwd, setCwd] = useState("");
|
||||||
|
const [timeoutMs, setTimeoutMs] = useState("30000");
|
||||||
|
const [maxOutputBytes, setMaxOutputBytes] = useState("");
|
||||||
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<ProcessRunResponse | null>(null);
|
||||||
|
|
||||||
|
const handleRun = async () => {
|
||||||
|
const trimmedCommand = command.trim();
|
||||||
|
if (!trimmedCommand) {
|
||||||
|
setError("Command is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRunning(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await getClient().runProcess({
|
||||||
|
command: trimmedCommand,
|
||||||
|
args: parseArgs(argsText),
|
||||||
|
cwd: cwd.trim() || undefined,
|
||||||
|
timeoutMs: timeoutMs.trim() ? Number(timeoutMs) : undefined,
|
||||||
|
maxOutputBytes: maxOutputBytes.trim() ? Number(maxOutputBytes) : undefined,
|
||||||
|
});
|
||||||
|
setResult(response);
|
||||||
|
} catch (runError) {
|
||||||
|
const detail = runError instanceof SandboxAgentError ? runError.problem?.detail : undefined;
|
||||||
|
setError(detail || (runError instanceof Error ? runError.message : "Unable to run process."));
|
||||||
|
setResult(null);
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="process-run-container">
|
||||||
|
<div className="process-run-form">
|
||||||
|
<div className="process-run-row">
|
||||||
|
<div className="process-run-field process-run-field-grow">
|
||||||
|
<label className="label">Command</label>
|
||||||
|
<input
|
||||||
|
className="setup-input mono"
|
||||||
|
value={command}
|
||||||
|
onChange={(event) => {
|
||||||
|
setCommand(event.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
placeholder="ls"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="process-run-field process-run-field-grow">
|
||||||
|
<label className="label">Working Directory</label>
|
||||||
|
<input
|
||||||
|
className="setup-input mono"
|
||||||
|
value={cwd}
|
||||||
|
onChange={(event) => {
|
||||||
|
setCwd(event.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
placeholder="/workspace"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="process-run-field">
|
||||||
|
<label className="label">Arguments</label>
|
||||||
|
<textarea
|
||||||
|
className="setup-input mono"
|
||||||
|
rows={2}
|
||||||
|
value={argsText}
|
||||||
|
onChange={(event) => {
|
||||||
|
setArgsText(event.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
placeholder={"One argument per line, e.g.\n-lc"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="process-advanced-toggle"
|
||||||
|
onClick={() => setShowAdvanced((prev) => !prev)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{showAdvanced ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
|
Advanced
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="process-run-row">
|
||||||
|
<div className="process-run-field process-run-field-grow">
|
||||||
|
<label className="label">Timeout (ms)</label>
|
||||||
|
<input
|
||||||
|
className="setup-input mono"
|
||||||
|
value={timeoutMs}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTimeoutMs(event.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
placeholder="30000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="process-run-field process-run-field-grow">
|
||||||
|
<label className="label">Max Output Bytes</label>
|
||||||
|
<input
|
||||||
|
className="setup-input mono"
|
||||||
|
value={maxOutputBytes}
|
||||||
|
onChange={(event) => {
|
||||||
|
setMaxOutputBytes(event.target.value);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
placeholder="Default"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? <div className="banner error">{error}</div> : null}
|
||||||
|
|
||||||
|
<button className="button primary small" onClick={() => void handleRun()} disabled={running} style={{ alignSelf: "flex-start" }}>
|
||||||
|
{running ? <Loader2 className="button-icon spinner-icon" /> : <Play className="button-icon" />}
|
||||||
|
{running ? "Running..." : "Run"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result ? (
|
||||||
|
<div className="process-run-result">
|
||||||
|
<div className="process-run-result-header">
|
||||||
|
<span className={`pill ${result.timedOut ? "warning" : result.exitCode === 0 ? "success" : "danger"}`}>
|
||||||
|
{result.timedOut ? "Timed Out" : `exit ${result.exitCode ?? "?"}`}
|
||||||
|
</span>
|
||||||
|
<span className="card-meta">{result.durationMs}ms</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="process-run-output">
|
||||||
|
<div className="process-run-output-section">
|
||||||
|
<div className="process-run-output-label">stdout{result.stdoutTruncated ? " (truncated)" : ""}</div>
|
||||||
|
<pre className="process-log-block">{result.stdout || "(empty)"}</pre>
|
||||||
|
</div>
|
||||||
|
<div className="process-run-output-section">
|
||||||
|
<div className="process-run-output-label">stderr{result.stderrTruncated ? " (truncated)" : ""}</div>
|
||||||
|
<pre className="process-log-block">{result.stderr || "(empty)"}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProcessRunTab;
|
||||||
|
|
@ -0,0 +1,433 @@
|
||||||
|
import { ChevronDown, ChevronRight, Loader2, Play, RefreshCw, Skull, SquareTerminal, Trash2 } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { ProcessTerminal } from "@sandbox-agent/react";
|
||||||
|
import { SandboxAgentError } from "sandbox-agent";
|
||||||
|
import type { ProcessInfo, SandboxAgent } from "sandbox-agent";
|
||||||
|
|
||||||
|
const extractErrorMessage = (error: unknown, fallback: string): string => {
|
||||||
|
if (error instanceof SandboxAgentError && error.problem?.detail) return error.problem.detail;
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const decodeBase64Utf8 = (value: string): string => {
|
||||||
|
try {
|
||||||
|
const bytes = Uint8Array.from(window.atob(value), (char) => char.charCodeAt(0));
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTime = (value: number | null | undefined): string => {
|
||||||
|
if (!value) {
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseArgs = (value: string): string[] => value.split("\n").map((part) => part.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
const formatCommandSummary = (process: Pick<ProcessInfo, "command" | "args">): string => {
|
||||||
|
return [process.command, ...process.args].join(" ").trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const canOpenTerminal = (process: ProcessInfo | null | undefined): boolean => {
|
||||||
|
return Boolean(process && process.status === "running" && process.interactive && process.tty);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProcessesTab = ({
|
||||||
|
getClient,
|
||||||
|
}: {
|
||||||
|
getClient: () => SandboxAgent;
|
||||||
|
}) => {
|
||||||
|
const [processes, setProcesses] = useState<ProcessInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [command, setCommand] = useState("");
|
||||||
|
const [argsText, setArgsText] = useState("");
|
||||||
|
const [cwd, setCwd] = useState("");
|
||||||
|
const [interactive, setInteractive] = useState(true);
|
||||||
|
const [tty, setTty] = useState(true);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(true);
|
||||||
|
|
||||||
|
const [selectedProcessId, setSelectedProcessId] = useState<string | null>(null);
|
||||||
|
const [logsText, setLogsText] = useState("");
|
||||||
|
const [logsLoading, setLogsLoading] = useState(false);
|
||||||
|
const [logsError, setLogsError] = useState<string | null>(null);
|
||||||
|
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||||
|
const [actingProcessId, setActingProcessId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadProcesses = useCallback(async (mode: "initial" | "refresh" = "initial") => {
|
||||||
|
if (mode === "initial") {
|
||||||
|
setLoading(true);
|
||||||
|
} else {
|
||||||
|
setRefreshing(true);
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await getClient().listProcesses();
|
||||||
|
setProcesses(response.processes);
|
||||||
|
setSelectedProcessId((current) => {
|
||||||
|
if (!current) {
|
||||||
|
return response.processes[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
return response.processes.some((listedProcess) => listedProcess.id === current)
|
||||||
|
? current
|
||||||
|
: response.processes[0]?.id ?? null;
|
||||||
|
});
|
||||||
|
} catch (loadError) {
|
||||||
|
setError(extractErrorMessage(loadError, "Unable to load processes."));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [getClient]);
|
||||||
|
|
||||||
|
const loadSelectedLogs = useCallback(async (process: ProcessInfo | null) => {
|
||||||
|
if (!process) {
|
||||||
|
setLogsText("");
|
||||||
|
setLogsError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLogsLoading(true);
|
||||||
|
setLogsError(null);
|
||||||
|
try {
|
||||||
|
const response = await getClient().getProcessLogs(process.id, {
|
||||||
|
stream: process.tty ? "pty" : "combined",
|
||||||
|
tail: 200,
|
||||||
|
});
|
||||||
|
const text = response.entries.map((logEntry) => decodeBase64Utf8(logEntry.data)).join("");
|
||||||
|
setLogsText(text);
|
||||||
|
} catch (loadError) {
|
||||||
|
setLogsError(extractErrorMessage(loadError, "Unable to load process logs."));
|
||||||
|
setLogsText("");
|
||||||
|
} finally {
|
||||||
|
setLogsLoading(false);
|
||||||
|
}
|
||||||
|
}, [getClient]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadProcesses();
|
||||||
|
}, [loadProcesses]);
|
||||||
|
|
||||||
|
const selectedProcess = useMemo(
|
||||||
|
() => processes.find((process) => process.id === selectedProcessId) ?? null,
|
||||||
|
[processes, selectedProcessId]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSelectedLogs(selectedProcess);
|
||||||
|
if (!canOpenTerminal(selectedProcess)) {
|
||||||
|
setTerminalOpen(false);
|
||||||
|
}
|
||||||
|
}, [loadSelectedLogs, selectedProcess]);
|
||||||
|
|
||||||
|
const handleCreateProcess = async () => {
|
||||||
|
const trimmedCommand = command.trim();
|
||||||
|
if (!trimmedCommand) {
|
||||||
|
setCreateError("Command is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreating(true);
|
||||||
|
setCreateError(null);
|
||||||
|
try {
|
||||||
|
const created = await getClient().createProcess({
|
||||||
|
command: trimmedCommand,
|
||||||
|
args: parseArgs(argsText),
|
||||||
|
cwd: cwd.trim() || undefined,
|
||||||
|
interactive,
|
||||||
|
tty,
|
||||||
|
});
|
||||||
|
await loadProcesses("refresh");
|
||||||
|
setSelectedProcessId(created.id);
|
||||||
|
setTerminalOpen(created.interactive && created.tty);
|
||||||
|
setCommand("");
|
||||||
|
setArgsText("");
|
||||||
|
setCwd("");
|
||||||
|
setInteractive(true);
|
||||||
|
setTty(true);
|
||||||
|
} catch (createFailure) {
|
||||||
|
setCreateError(extractErrorMessage(createFailure, "Unable to create process."));
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = async (processId: string, action: "stop" | "kill" | "delete") => {
|
||||||
|
setActingProcessId(`${action}:${processId}`);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const client = getClient();
|
||||||
|
if (action === "stop") {
|
||||||
|
await client.stopProcess(processId, { waitMs: 2_000 });
|
||||||
|
} else if (action === "kill") {
|
||||||
|
await client.killProcess(processId, { waitMs: 2_000 });
|
||||||
|
} else {
|
||||||
|
await client.deleteProcess(processId);
|
||||||
|
}
|
||||||
|
await loadProcesses("refresh");
|
||||||
|
} catch (actionError) {
|
||||||
|
setError(extractErrorMessage(actionError, `Unable to ${action} process.`));
|
||||||
|
} finally {
|
||||||
|
setActingProcessId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTerminalExit = useCallback(() => {
|
||||||
|
void loadProcesses("refresh");
|
||||||
|
}, [loadProcesses]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="processes-container">
|
||||||
|
{/* Create form */}
|
||||||
|
<div className="processes-section">
|
||||||
|
<button
|
||||||
|
className="processes-section-toggle"
|
||||||
|
onClick={() => setShowCreateForm((prev) => !prev)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{showCreateForm ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||||
|
<span>Create Process</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showCreateForm && (
|
||||||
|
<div className="process-create-form">
|
||||||
|
<div className="process-run-row">
|
||||||
|
<div className="process-run-field process-run-field-grow">
|
||||||
|
<label className="label">Command</label>
|
||||||
|
<input
|
||||||
|
className="setup-input mono"
|
||||||
|
value={command}
|
||||||
|
onChange={(event) => {
|
||||||
|
setCommand(event.target.value);
|
||||||
|
setCreateError(null);
|
||||||
|
}}
|
||||||
|
placeholder="bash"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="process-run-field process-run-field-grow">
|
||||||
|
<label className="label">Working Directory</label>
|
||||||
|
<input
|
||||||
|
className="setup-input mono"
|
||||||
|
value={cwd}
|
||||||
|
onChange={(event) => {
|
||||||
|
setCwd(event.target.value);
|
||||||
|
setCreateError(null);
|
||||||
|
}}
|
||||||
|
placeholder="/workspace"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="process-run-field">
|
||||||
|
<label className="label">Arguments</label>
|
||||||
|
<textarea
|
||||||
|
className="setup-input mono"
|
||||||
|
rows={2}
|
||||||
|
value={argsText}
|
||||||
|
onChange={(event) => {
|
||||||
|
setArgsText(event.target.value);
|
||||||
|
setCreateError(null);
|
||||||
|
}}
|
||||||
|
placeholder={"One argument per line"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="process-checkbox-row">
|
||||||
|
<label className="process-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={interactive}
|
||||||
|
onChange={(event) => {
|
||||||
|
setInteractive(event.target.checked);
|
||||||
|
if (!event.target.checked) {
|
||||||
|
setTty(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>interactive</span>
|
||||||
|
</label>
|
||||||
|
<label className="process-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={tty}
|
||||||
|
onChange={(event) => {
|
||||||
|
setTty(event.target.checked);
|
||||||
|
if (event.target.checked) {
|
||||||
|
setInteractive(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>tty</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createError ? <div className="banner error">{createError}</div> : null}
|
||||||
|
|
||||||
|
<button className="button primary small" onClick={() => void handleCreateProcess()} disabled={creating} style={{ alignSelf: "flex-start" }}>
|
||||||
|
{creating ? <Loader2 className="button-icon spinner-icon" /> : <Play className="button-icon" />}
|
||||||
|
{creating ? "Creating..." : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Process list */}
|
||||||
|
<div className="processes-section">
|
||||||
|
<div className="processes-list-header">
|
||||||
|
<span className="processes-section-label">Processes</span>
|
||||||
|
<button className="button secondary small" onClick={() => void loadProcesses("refresh")} disabled={loading || refreshing}>
|
||||||
|
<RefreshCw className={`button-icon ${loading || refreshing ? "spinner-icon" : ""}`} size={12} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="banner error">{error}</div> : null}
|
||||||
|
{loading ? <div className="card-meta">Loading...</div> : null}
|
||||||
|
{!loading && processes.length === 0 ? <div className="card-meta">No processes yet.</div> : null}
|
||||||
|
|
||||||
|
<div className="process-list">
|
||||||
|
{processes.map((process) => {
|
||||||
|
const isSelected = selectedProcessId === process.id;
|
||||||
|
const isStopping = actingProcessId === `stop:${process.id}`;
|
||||||
|
const isKilling = actingProcessId === `kill:${process.id}`;
|
||||||
|
const isDeleting = actingProcessId === `delete:${process.id}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={process.id}
|
||||||
|
className={`process-list-item ${isSelected ? "selected" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProcessId(process.id);
|
||||||
|
setTerminalOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="process-list-item-main">
|
||||||
|
<span className={`process-status-dot ${process.status}`} />
|
||||||
|
<span className="process-list-item-cmd mono">{formatCommandSummary(process)}</span>
|
||||||
|
{process.interactive && process.tty && (
|
||||||
|
<span className="pill neutral" style={{ fontSize: 9 }}>tty</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="process-list-item-meta">
|
||||||
|
<span>PID {process.pid ?? "?"}</span>
|
||||||
|
<span className="process-list-item-id">{process.id.slice(0, 8)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="process-list-item-actions">
|
||||||
|
{canOpenTerminal(process) ? (
|
||||||
|
<button
|
||||||
|
className="button secondary small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedProcessId(process.id);
|
||||||
|
setTerminalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SquareTerminal className="button-icon" size={12} />
|
||||||
|
Terminal
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{process.status === "running" ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="button secondary small"
|
||||||
|
onClick={(e) => { e.stopPropagation(); void handleAction(process.id, "stop"); }}
|
||||||
|
disabled={Boolean(actingProcessId)}
|
||||||
|
>
|
||||||
|
{isStopping ? <Loader2 className="button-icon spinner-icon" size={12} /> : null}
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="button secondary small"
|
||||||
|
onClick={(e) => { e.stopPropagation(); void handleAction(process.id, "kill"); }}
|
||||||
|
disabled={Boolean(actingProcessId)}
|
||||||
|
>
|
||||||
|
{isKilling ? <Loader2 className="button-icon spinner-icon" size={12} /> : <Skull className="button-icon" size={12} />}
|
||||||
|
Kill
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{process.status === "exited" ? (
|
||||||
|
<button
|
||||||
|
className="button secondary small"
|
||||||
|
onClick={(e) => { e.stopPropagation(); void handleAction(process.id, "delete"); }}
|
||||||
|
disabled={Boolean(actingProcessId)}
|
||||||
|
>
|
||||||
|
{isDeleting ? <Loader2 className="button-icon spinner-icon" size={12} /> : <Trash2 className="button-icon" size={12} />}
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected process detail */}
|
||||||
|
{selectedProcess ? (
|
||||||
|
<div className="processes-section">
|
||||||
|
<div className="processes-section-label">Detail</div>
|
||||||
|
|
||||||
|
<div className="process-detail">
|
||||||
|
<div className="process-detail-header">
|
||||||
|
<span className="process-detail-cmd mono">{formatCommandSummary(selectedProcess)}</span>
|
||||||
|
<span className={`pill ${selectedProcess.status === "running" ? "success" : "neutral"}`}>{selectedProcess.status}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="process-detail-meta">
|
||||||
|
<span>PID: {selectedProcess.pid ?? "?"}</span>
|
||||||
|
<span>Created: {formatDateTime(selectedProcess.createdAtMs)}</span>
|
||||||
|
{selectedProcess.exitedAtMs ? <span>Exited: {formatDateTime(selectedProcess.exitedAtMs)}</span> : null}
|
||||||
|
{selectedProcess.exitCode != null ? <span>Exit code: {selectedProcess.exitCode}</span> : null}
|
||||||
|
<span className="mono" style={{ opacity: 0.6 }}>{selectedProcess.id}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal */}
|
||||||
|
{terminalOpen && canOpenTerminal(selectedProcess) ? (
|
||||||
|
<ProcessTerminal
|
||||||
|
client={getClient()}
|
||||||
|
processId={selectedProcess.id}
|
||||||
|
style={{ marginTop: 4 }}
|
||||||
|
onExit={handleTerminalExit}
|
||||||
|
/>
|
||||||
|
) : canOpenTerminal(selectedProcess) ? (
|
||||||
|
<button
|
||||||
|
className="button secondary small"
|
||||||
|
onClick={() => setTerminalOpen(true)}
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
>
|
||||||
|
<SquareTerminal className="button-icon" size={12} />
|
||||||
|
Open Terminal
|
||||||
|
</button>
|
||||||
|
) : selectedProcess.interactive && selectedProcess.tty ? (
|
||||||
|
<div className="process-terminal-empty">
|
||||||
|
Terminal available while process is running.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Logs */}
|
||||||
|
<div className="process-detail-logs">
|
||||||
|
<div className="process-detail-logs-header">
|
||||||
|
<span className="label">Logs</span>
|
||||||
|
<button className="button secondary small" onClick={() => void loadSelectedLogs(selectedProcess)} disabled={logsLoading}>
|
||||||
|
{logsLoading ? <Loader2 className="button-icon spinner-icon" size={12} /> : <RefreshCw className="button-icon" size={12} />}
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{logsError ? <div className="banner error">{logsError}</div> : null}
|
||||||
|
<pre className="process-log-block">{logsText || (logsLoading ? "Loading..." : "(no output)")}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProcessesTab;
|
||||||
|
|
@ -10,6 +10,7 @@ export default defineConfig(({ command }) => ({
|
||||||
"/v1": {
|
"/v1": {
|
||||||
target: "http://localhost:2468",
|
target: "http://localhost:2468",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
40
pnpm-lock.yaml
generated
40
pnpm-lock.yaml
generated
|
|
@ -434,6 +434,9 @@ importers:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1(react@18.3.1)
|
version: 18.3.1(react@18.3.1)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@sandbox-agent/react':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../../sdks/react
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^18.3.3
|
specifier: ^18.3.3
|
||||||
version: 18.3.27
|
version: 18.3.27
|
||||||
|
|
@ -768,6 +771,28 @@ importers:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
|
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
|
|
||||||
|
sdks/react:
|
||||||
|
dependencies:
|
||||||
|
ghostty-web:
|
||||||
|
specifier: ^0.4.0
|
||||||
|
version: 0.4.0
|
||||||
|
devDependencies:
|
||||||
|
'@types/react':
|
||||||
|
specifier: ^18.3.3
|
||||||
|
version: 18.3.27
|
||||||
|
react:
|
||||||
|
specifier: ^18.3.1
|
||||||
|
version: 18.3.1
|
||||||
|
sandbox-agent:
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../typescript
|
||||||
|
tsup:
|
||||||
|
specifier: ^8.0.0
|
||||||
|
version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.0
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
sdks/typescript:
|
sdks/typescript:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@sandbox-agent/cli-shared':
|
'@sandbox-agent/cli-shared':
|
||||||
|
|
@ -3723,6 +3748,9 @@ packages:
|
||||||
get-tsconfig@4.13.0:
|
get-tsconfig@4.13.0:
|
||||||
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
|
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
|
||||||
|
|
||||||
|
ghostty-web@0.4.0:
|
||||||
|
resolution: {integrity: sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg==}
|
||||||
|
|
||||||
github-from-package@0.0.0:
|
github-from-package@0.0.0:
|
||||||
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
||||||
|
|
||||||
|
|
@ -8020,14 +8048,6 @@ snapshots:
|
||||||
chai: 5.3.3
|
chai: 5.3.3
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
'@vitest/mocker@3.2.4(vite@5.4.21(@types/node@22.19.7))':
|
|
||||||
dependencies:
|
|
||||||
'@vitest/spy': 3.2.4
|
|
||||||
estree-walker: 3.0.3
|
|
||||||
magic-string: 0.30.21
|
|
||||||
optionalDependencies:
|
|
||||||
vite: 5.4.21(@types/node@22.19.7)
|
|
||||||
|
|
||||||
'@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.3.5))':
|
'@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.3.5))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 3.2.4
|
'@vitest/spy': 3.2.4
|
||||||
|
|
@ -9032,6 +9052,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
resolve-pkg-maps: 1.0.0
|
resolve-pkg-maps: 1.0.0
|
||||||
|
|
||||||
|
ghostty-web@0.4.0: {}
|
||||||
|
|
||||||
github-from-package@0.0.0: {}
|
github-from-package@0.0.0: {}
|
||||||
|
|
||||||
github-slugger@2.0.0: {}
|
github-slugger@2.0.0: {}
|
||||||
|
|
@ -11085,7 +11107,7 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/chai': 5.2.3
|
'@types/chai': 5.2.3
|
||||||
'@vitest/expect': 3.2.4
|
'@vitest/expect': 3.2.4
|
||||||
'@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@22.19.7))
|
'@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.3.5))
|
||||||
'@vitest/pretty-format': 3.2.4
|
'@vitest/pretty-format': 3.2.4
|
||||||
'@vitest/runner': 3.2.4
|
'@vitest/runner': 3.2.4
|
||||||
'@vitest/snapshot': 3.2.4
|
'@vitest/snapshot': 3.2.4
|
||||||
|
|
|
||||||
40
sdks/react/package.json
Normal file
40
sdks/react/package.json
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"name": "@sandbox-agent/react",
|
||||||
|
"version": "0.2.2",
|
||||||
|
"description": "React components for Sandbox Agent frontend integrations",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/rivet-dev/sandbox-agent"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"sandbox-agent": "^0.2.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ghostty-web": "^0.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"sandbox-agent": "workspace:*",
|
||||||
|
"tsup": "^8.0.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
271
sdks/react/src/ProcessTerminal.tsx
Normal file
271
sdks/react/src/ProcessTerminal.tsx
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { FitAddon as GhosttyFitAddon, Terminal as GhosttyTerminal } from "ghostty-web";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { SandboxAgent, TerminalErrorStatus, TerminalExitStatus } from "sandbox-agent";
|
||||||
|
|
||||||
|
type ConnectionState = "connecting" | "ready" | "closed" | "error";
|
||||||
|
|
||||||
|
export type ProcessTerminalClient = Pick<SandboxAgent, "connectProcessTerminal">;
|
||||||
|
|
||||||
|
export interface ProcessTerminalProps {
|
||||||
|
client: ProcessTerminalClient;
|
||||||
|
processId: string;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
terminalStyle?: CSSProperties;
|
||||||
|
height?: number | string;
|
||||||
|
onExit?: (status: TerminalExitStatus) => void;
|
||||||
|
onError?: (error: TerminalErrorStatus | Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminalTheme = {
|
||||||
|
background: "#09090b",
|
||||||
|
foreground: "#f4f4f5",
|
||||||
|
cursor: "#f97316",
|
||||||
|
cursorAccent: "#09090b",
|
||||||
|
selectionBackground: "#27272a",
|
||||||
|
black: "#18181b",
|
||||||
|
red: "#f87171",
|
||||||
|
green: "#4ade80",
|
||||||
|
yellow: "#fbbf24",
|
||||||
|
blue: "#60a5fa",
|
||||||
|
magenta: "#f472b6",
|
||||||
|
cyan: "#22d3ee",
|
||||||
|
white: "#e4e4e7",
|
||||||
|
brightBlack: "#3f3f46",
|
||||||
|
brightRed: "#fb7185",
|
||||||
|
brightGreen: "#86efac",
|
||||||
|
brightYellow: "#fde047",
|
||||||
|
brightBlue: "#93c5fd",
|
||||||
|
brightMagenta: "#f9a8d4",
|
||||||
|
brightCyan: "#67e8f9",
|
||||||
|
brightWhite: "#fafafa",
|
||||||
|
};
|
||||||
|
|
||||||
|
const shellStyle: CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
|
borderRadius: 10,
|
||||||
|
background: "rgba(0, 0, 0, 0.3)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBarStyle: CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: 12,
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderBottom: "1px solid rgba(255, 255, 255, 0.08)",
|
||||||
|
background: "rgba(0, 0, 0, 0.2)",
|
||||||
|
color: "rgba(244, 244, 245, 0.86)",
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1.4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const hostBaseStyle: CSSProperties = {
|
||||||
|
minHeight: 320,
|
||||||
|
padding: 10,
|
||||||
|
overflow: "hidden",
|
||||||
|
};
|
||||||
|
|
||||||
|
const exitCodeStyle: CSSProperties = {
|
||||||
|
fontFamily: "ui-monospace, SFMono-Regular, SF Mono, Menlo, monospace",
|
||||||
|
opacity: 0.72,
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (state: ConnectionState): string => {
|
||||||
|
switch (state) {
|
||||||
|
case "ready":
|
||||||
|
return "#4ade80";
|
||||||
|
case "error":
|
||||||
|
return "#fb7185";
|
||||||
|
case "closed":
|
||||||
|
return "#fbbf24";
|
||||||
|
default:
|
||||||
|
return "rgba(244, 244, 245, 0.72)";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProcessTerminal = ({
|
||||||
|
client,
|
||||||
|
processId,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
terminalStyle,
|
||||||
|
height = 360,
|
||||||
|
onExit,
|
||||||
|
onError,
|
||||||
|
}: ProcessTerminalProps) => {
|
||||||
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [connectionState, setConnectionState] = useState<ConnectionState>("connecting");
|
||||||
|
const [statusMessage, setStatusMessage] = useState("Connecting to PTY...");
|
||||||
|
const [exitCode, setExitCode] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let terminal: GhosttyTerminal | null = null;
|
||||||
|
let fitAddon: GhosttyFitAddon | null = null;
|
||||||
|
let session: ReturnType<ProcessTerminalClient["connectProcessTerminal"]> | null = null;
|
||||||
|
let resizeRaf = 0;
|
||||||
|
let removeDataListener: { dispose(): void } | null = null;
|
||||||
|
let removeResizeListener: { dispose(): void } | null = null;
|
||||||
|
|
||||||
|
setConnectionState("connecting");
|
||||||
|
setStatusMessage("Connecting to PTY...");
|
||||||
|
setExitCode(null);
|
||||||
|
|
||||||
|
const syncSize = () => {
|
||||||
|
if (!terminal || !session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.resize({
|
||||||
|
cols: terminal.cols,
|
||||||
|
rows: terminal.rows,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
try {
|
||||||
|
const ghostty = await import("ghostty-web");
|
||||||
|
await ghostty.init();
|
||||||
|
|
||||||
|
if (cancelled || !hostRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
terminal = new ghostty.Terminal({
|
||||||
|
allowTransparency: true,
|
||||||
|
cursorBlink: true,
|
||||||
|
cursorStyle: "block",
|
||||||
|
fontFamily: "ui-monospace, SFMono-Regular, SF Mono, Menlo, monospace",
|
||||||
|
fontSize: 13,
|
||||||
|
smoothScrollDuration: 90,
|
||||||
|
theme: terminalTheme,
|
||||||
|
});
|
||||||
|
fitAddon = new ghostty.FitAddon();
|
||||||
|
|
||||||
|
terminal.open(hostRef.current);
|
||||||
|
const terminalRoot = hostRef.current.firstElementChild;
|
||||||
|
if (terminalRoot instanceof HTMLElement) {
|
||||||
|
terminalRoot.style.width = "100%";
|
||||||
|
terminalRoot.style.height = "100%";
|
||||||
|
}
|
||||||
|
terminal.loadAddon(fitAddon);
|
||||||
|
fitAddon.fit();
|
||||||
|
fitAddon.observeResize();
|
||||||
|
terminal.focus();
|
||||||
|
|
||||||
|
removeDataListener = terminal.onData((data) => {
|
||||||
|
session?.sendInput(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
removeResizeListener = terminal.onResize(() => {
|
||||||
|
if (resizeRaf) {
|
||||||
|
window.cancelAnimationFrame(resizeRaf);
|
||||||
|
}
|
||||||
|
resizeRaf = window.requestAnimationFrame(syncSize);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextSession = client.connectProcessTerminal(processId);
|
||||||
|
session = nextSession;
|
||||||
|
|
||||||
|
nextSession.onReady((frame) => {
|
||||||
|
if (cancelled || frame.type !== "ready") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConnectionState("ready");
|
||||||
|
setStatusMessage("Connected");
|
||||||
|
syncSize();
|
||||||
|
});
|
||||||
|
|
||||||
|
nextSession.onData((bytes) => {
|
||||||
|
if (cancelled || !terminal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
terminal.write(bytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
nextSession.onExit((frame) => {
|
||||||
|
if (cancelled || frame.type !== "exit") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConnectionState("closed");
|
||||||
|
setExitCode(frame.exitCode ?? null);
|
||||||
|
setStatusMessage(
|
||||||
|
frame.exitCode == null ? "Process exited." : `Process exited with code ${frame.exitCode}.`
|
||||||
|
);
|
||||||
|
onExit?.(frame);
|
||||||
|
});
|
||||||
|
|
||||||
|
nextSession.onError((error) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConnectionState("error");
|
||||||
|
setStatusMessage(error instanceof Error ? error.message : error.message);
|
||||||
|
onError?.(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
nextSession.onClose(() => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConnectionState((current) => (current === "error" ? current : "closed"));
|
||||||
|
setStatusMessage((current) => (current === "Connected" ? "Terminal disconnected." : current));
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextError = error instanceof Error ? error : new Error("Failed to initialize terminal.");
|
||||||
|
setConnectionState("error");
|
||||||
|
setStatusMessage(nextError.message);
|
||||||
|
onError?.(nextError);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (resizeRaf) {
|
||||||
|
window.cancelAnimationFrame(resizeRaf);
|
||||||
|
}
|
||||||
|
removeDataListener?.dispose();
|
||||||
|
removeResizeListener?.dispose();
|
||||||
|
session?.close();
|
||||||
|
terminal?.dispose();
|
||||||
|
};
|
||||||
|
}, [client, onError, onExit, processId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} style={{ ...shellStyle, ...style }}>
|
||||||
|
<div style={statusBarStyle}>
|
||||||
|
<span style={{ color: getStatusColor(connectionState) }}>{statusMessage}</span>
|
||||||
|
{exitCode != null ? <span style={exitCodeStyle}>exit={exitCode}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={hostRef}
|
||||||
|
role="presentation"
|
||||||
|
style={{
|
||||||
|
...hostBaseStyle,
|
||||||
|
height,
|
||||||
|
...terminalStyle,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
hostRef.current?.querySelector("textarea")?.focus();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
6
sdks/react/src/index.ts
Normal file
6
sdks/react/src/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export { ProcessTerminal } from "./ProcessTerminal.tsx";
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ProcessTerminalClient,
|
||||||
|
ProcessTerminalProps,
|
||||||
|
} from "./ProcessTerminal.tsx";
|
||||||
17
sdks/react/tsconfig.json
Normal file
17
sdks/react/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
10
sdks/react/tsup.config.ts
Normal file
10
sdks/react/tsup.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/index.ts"],
|
||||||
|
format: ["esm"],
|
||||||
|
dts: true,
|
||||||
|
sourcemap: true,
|
||||||
|
clean: true,
|
||||||
|
target: "es2022",
|
||||||
|
});
|
||||||
|
|
@ -56,6 +56,8 @@ import {
|
||||||
type ProcessRunRequest,
|
type ProcessRunRequest,
|
||||||
type ProcessRunResponse,
|
type ProcessRunResponse,
|
||||||
type ProcessSignalQuery,
|
type ProcessSignalQuery,
|
||||||
|
type ProcessTerminalClientFrame,
|
||||||
|
type ProcessTerminalServerFrame,
|
||||||
type ProcessTerminalResizeRequest,
|
type ProcessTerminalResizeRequest,
|
||||||
type ProcessTerminalResizeResponse,
|
type ProcessTerminalResizeResponse,
|
||||||
type SessionEvent,
|
type SessionEvent,
|
||||||
|
|
@ -63,6 +65,10 @@ import {
|
||||||
type SessionRecord,
|
type SessionRecord,
|
||||||
type SkillsConfig,
|
type SkillsConfig,
|
||||||
type SkillsConfigQuery,
|
type SkillsConfigQuery,
|
||||||
|
type TerminalErrorStatus,
|
||||||
|
type TerminalExitStatus,
|
||||||
|
type TerminalReadyStatus,
|
||||||
|
type TerminalResizePayload,
|
||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
|
|
||||||
const API_PREFIX = "/v1";
|
const API_PREFIX = "/v1";
|
||||||
|
|
@ -158,6 +164,8 @@ export interface ProcessTerminalConnectOptions extends ProcessTerminalWebSocketU
|
||||||
WebSocket?: typeof WebSocket;
|
WebSocket?: typeof WebSocket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProcessTerminalSessionOptions = ProcessTerminalConnectOptions;
|
||||||
|
|
||||||
export class SandboxAgentError extends Error {
|
export class SandboxAgentError extends Error {
|
||||||
readonly status: number;
|
readonly status: number;
|
||||||
readonly problem?: ProblemDetails;
|
readonly problem?: ProblemDetails;
|
||||||
|
|
@ -586,6 +594,173 @@ export class LiveAcpConnection {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ProcessTerminalSession {
|
||||||
|
readonly socket: WebSocket;
|
||||||
|
readonly closed: Promise<void>;
|
||||||
|
|
||||||
|
private readonly readyListeners = new Set<(status: TerminalReadyStatus) => void>();
|
||||||
|
private readonly dataListeners = new Set<(data: Uint8Array) => void>();
|
||||||
|
private readonly exitListeners = new Set<(status: TerminalExitStatus) => void>();
|
||||||
|
private readonly errorListeners = new Set<(error: TerminalErrorStatus | Error) => void>();
|
||||||
|
private readonly closeListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
private closeSignalSent = false;
|
||||||
|
private closedResolve!: () => void;
|
||||||
|
|
||||||
|
constructor(socket: WebSocket) {
|
||||||
|
this.socket = socket;
|
||||||
|
this.socket.binaryType = "arraybuffer";
|
||||||
|
this.closed = new Promise<void>((resolve) => {
|
||||||
|
this.closedResolve = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.addEventListener("message", (event) => {
|
||||||
|
void this.handleMessage(event.data);
|
||||||
|
});
|
||||||
|
this.socket.addEventListener("error", () => {
|
||||||
|
this.emitError(new Error("Terminal websocket connection failed."));
|
||||||
|
});
|
||||||
|
this.socket.addEventListener("close", () => {
|
||||||
|
this.closedResolve();
|
||||||
|
for (const listener of this.closeListeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onReady(listener: (status: TerminalReadyStatus) => void): () => void {
|
||||||
|
this.readyListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.readyListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onData(listener: (data: Uint8Array) => void): () => void {
|
||||||
|
this.dataListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.dataListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onExit(listener: (status: TerminalExitStatus) => void): () => void {
|
||||||
|
this.exitListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.exitListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onError(listener: (error: TerminalErrorStatus | Error) => void): () => void {
|
||||||
|
this.errorListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.errorListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose(listener: () => void): () => void {
|
||||||
|
this.closeListeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.closeListeners.delete(listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
sendInput(data: string | ArrayBuffer | ArrayBufferView): void {
|
||||||
|
const payload = encodeTerminalInput(data);
|
||||||
|
this.sendFrame({
|
||||||
|
type: "input",
|
||||||
|
data: payload.data,
|
||||||
|
encoding: payload.encoding,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(payload: TerminalResizePayload): void {
|
||||||
|
this.sendFrame({
|
||||||
|
type: "resize",
|
||||||
|
cols: payload.cols,
|
||||||
|
rows: payload.rows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
if (this.socket.readyState === WS_READY_STATE_CONNECTING) {
|
||||||
|
this.socket.addEventListener(
|
||||||
|
"open",
|
||||||
|
() => {
|
||||||
|
this.close();
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.socket.readyState === WS_READY_STATE_OPEN) {
|
||||||
|
if (!this.closeSignalSent) {
|
||||||
|
this.closeSignalSent = true;
|
||||||
|
this.sendFrame({ type: "close" });
|
||||||
|
}
|
||||||
|
this.socket.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.socket.readyState !== WS_READY_STATE_CLOSED) {
|
||||||
|
this.socket.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleMessage(data: unknown): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (typeof data === "string") {
|
||||||
|
const frame = parseProcessTerminalServerFrame(data);
|
||||||
|
if (!frame) {
|
||||||
|
this.emitError(new Error("Received invalid terminal control frame."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.type === "ready") {
|
||||||
|
for (const listener of this.readyListeners) {
|
||||||
|
listener(frame);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.type === "exit") {
|
||||||
|
for (const listener of this.exitListeners) {
|
||||||
|
listener(frame);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitError(frame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await decodeTerminalBytes(data);
|
||||||
|
for (const listener of this.dataListeners) {
|
||||||
|
listener(bytes);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.emitError(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sendFrame(frame: ProcessTerminalClientFrame): void {
|
||||||
|
if (this.socket.readyState !== WS_READY_STATE_OPEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.socket.send(JSON.stringify(frame));
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitError(error: TerminalErrorStatus | Error): void {
|
||||||
|
for (const listener of this.errorListeners) {
|
||||||
|
listener(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WS_READY_STATE_CONNECTING = 0;
|
||||||
|
const WS_READY_STATE_OPEN = 1;
|
||||||
|
const WS_READY_STATE_CLOSED = 3;
|
||||||
|
|
||||||
export class SandboxAgent {
|
export class SandboxAgent {
|
||||||
private readonly baseUrl: string;
|
private readonly baseUrl: string;
|
||||||
private readonly token?: string;
|
private readonly token?: string;
|
||||||
|
|
@ -1344,6 +1519,13 @@ export class SandboxAgent {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
connectProcessTerminal(
|
||||||
|
id: string,
|
||||||
|
options: ProcessTerminalSessionOptions = {},
|
||||||
|
): ProcessTerminalSession {
|
||||||
|
return new ProcessTerminalSession(this.connectProcessTerminalWebSocket(id, options));
|
||||||
|
}
|
||||||
|
|
||||||
private async getLiveConnection(agent: string): Promise<LiveAcpConnection> {
|
private async getLiveConnection(agent: string): Promise<LiveAcpConnection> {
|
||||||
await this.awaitHealthy();
|
await this.awaitHealthy();
|
||||||
|
|
||||||
|
|
@ -1757,6 +1939,91 @@ type NormalizedHealthWaitOptions =
|
||||||
| { enabled: false; timeoutMs?: undefined; signal?: undefined }
|
| { enabled: false; timeoutMs?: undefined; signal?: undefined }
|
||||||
| { enabled: true; timeoutMs?: number; signal?: AbortSignal };
|
| { enabled: true; timeoutMs?: number; signal?: AbortSignal };
|
||||||
|
|
||||||
|
function parseProcessTerminalServerFrame(payload: string): ProcessTerminalServerFrame | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payload) as unknown;
|
||||||
|
if (!isRecord(parsed) || typeof parsed.type !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.type === "ready" && typeof parsed.processId === "string") {
|
||||||
|
return parsed as ProcessTerminalServerFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
parsed.type === "exit" &&
|
||||||
|
(parsed.exitCode === undefined ||
|
||||||
|
parsed.exitCode === null ||
|
||||||
|
typeof parsed.exitCode === "number")
|
||||||
|
) {
|
||||||
|
return parsed as ProcessTerminalServerFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.type === "error" && typeof parsed.message === "string") {
|
||||||
|
return parsed as ProcessTerminalServerFrame;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeTerminalInput(
|
||||||
|
data: string | ArrayBuffer | ArrayBufferView,
|
||||||
|
): { data: string; encoding?: "base64" } {
|
||||||
|
if (typeof data === "string") {
|
||||||
|
return { data };
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = encodeTerminalBytes(data);
|
||||||
|
return {
|
||||||
|
data: bytesToBase64(bytes),
|
||||||
|
encoding: "base64",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeTerminalBytes(data: ArrayBuffer | ArrayBufferView): Uint8Array {
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
return new Uint8Array(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decodeTerminalBytes(data: unknown): Promise<Uint8Array> {
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
return new Uint8Array(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ArrayBuffer.isView(data)) {
|
||||||
|
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
||||||
|
return new Uint8Array(await data.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unsupported terminal frame payload: ${String(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
if (typeof Buffer !== "undefined") {
|
||||||
|
return Buffer.from(bytes).toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof btoa === "function") {
|
||||||
|
let binary = "";
|
||||||
|
const chunkSize = 0x8000;
|
||||||
|
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Base64 encoding is not available in this environment.");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-select and call `authenticate` based on the agent's advertised auth methods.
|
* Auto-select and call `authenticate` based on the agent's advertised auth methods.
|
||||||
* Prefers env-var-based methods that the server process already has configured.
|
* Prefers env-var-based methods that the server process already has configured.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export {
|
export {
|
||||||
LiveAcpConnection,
|
LiveAcpConnection,
|
||||||
|
ProcessTerminalSession,
|
||||||
SandboxAgent,
|
SandboxAgent,
|
||||||
SandboxAgentError,
|
SandboxAgentError,
|
||||||
Session,
|
Session,
|
||||||
|
|
@ -19,6 +20,7 @@ export type {
|
||||||
ProcessLogListener,
|
ProcessLogListener,
|
||||||
ProcessLogSubscription,
|
ProcessLogSubscription,
|
||||||
ProcessTerminalConnectOptions,
|
ProcessTerminalConnectOptions,
|
||||||
|
ProcessTerminalSessionOptions,
|
||||||
ProcessTerminalWebSocketUrlOptions,
|
ProcessTerminalWebSocketUrlOptions,
|
||||||
SandboxAgentConnectOptions,
|
SandboxAgentConnectOptions,
|
||||||
SandboxAgentStartOptions,
|
SandboxAgentStartOptions,
|
||||||
|
|
@ -88,6 +90,11 @@ export type {
|
||||||
SessionRecord,
|
SessionRecord,
|
||||||
SkillsConfig,
|
SkillsConfig,
|
||||||
SkillsConfigQuery,
|
SkillsConfigQuery,
|
||||||
|
TerminalErrorStatus,
|
||||||
|
TerminalExitStatus,
|
||||||
|
TerminalReadyStatus,
|
||||||
|
TerminalResizePayload,
|
||||||
|
TerminalStatusMessage,
|
||||||
} from "./types.ts";
|
} from "./types.ts";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,16 @@ export type ProcessTerminalServerFrame =
|
||||||
| ProcessTerminalExitFrame
|
| ProcessTerminalExitFrame
|
||||||
| ProcessTerminalErrorFrame;
|
| ProcessTerminalErrorFrame;
|
||||||
|
|
||||||
|
export type TerminalReadyStatus = ProcessTerminalReadyFrame;
|
||||||
|
export type TerminalExitStatus = ProcessTerminalExitFrame;
|
||||||
|
export type TerminalErrorStatus = ProcessTerminalErrorFrame;
|
||||||
|
export type TerminalStatusMessage = ProcessTerminalServerFrame;
|
||||||
|
|
||||||
|
export interface TerminalResizePayload {
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionRecord {
|
export interface SessionRecord {
|
||||||
id: string;
|
id: string;
|
||||||
agent: string;
|
agent: string;
|
||||||
|
|
|
||||||
|
|
@ -136,22 +136,6 @@ function writeTarChecksum(buffer: Buffer, checksum: number): void {
|
||||||
buffer[155] = 0x20;
|
buffer[155] = 0x20;
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeSocketPayload(data: unknown): string {
|
|
||||||
if (typeof data === "string") {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
if (data instanceof ArrayBuffer) {
|
|
||||||
return Buffer.from(data).toString("utf8");
|
|
||||||
}
|
|
||||||
if (ArrayBuffer.isView(data)) {
|
|
||||||
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
|
|
||||||
}
|
|
||||||
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
|
||||||
throw new Error("Blob socket payloads are not supported in this test");
|
|
||||||
}
|
|
||||||
throw new Error(`Unsupported socket payload type: ${typeof data}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeProcessLogData(data: string, encoding: string): string {
|
function decodeProcessLogData(data: string, encoding: string): string {
|
||||||
if (encoding === "base64") {
|
if (encoding === "base64") {
|
||||||
return Buffer.from(data, "base64").toString("utf8");
|
return Buffer.from(data, "base64").toString("utf8");
|
||||||
|
|
@ -816,37 +800,46 @@ describe("Integration: TypeScript SDK flat session API", () => {
|
||||||
const wsUrl = sdk.buildProcessTerminalWebSocketUrl(ttyProcess.id);
|
const wsUrl = sdk.buildProcessTerminalWebSocketUrl(ttyProcess.id);
|
||||||
expect(wsUrl.startsWith("ws://") || wsUrl.startsWith("wss://")).toBe(true);
|
expect(wsUrl.startsWith("ws://") || wsUrl.startsWith("wss://")).toBe(true);
|
||||||
|
|
||||||
const ws = sdk.connectProcessTerminalWebSocket(ttyProcess.id, {
|
const session = sdk.connectProcessTerminal(ttyProcess.id, {
|
||||||
WebSocket: WebSocket as unknown as typeof globalThis.WebSocket,
|
WebSocket: WebSocket as unknown as typeof globalThis.WebSocket,
|
||||||
});
|
});
|
||||||
ws.binaryType = "arraybuffer";
|
const readyFrames: string[] = [];
|
||||||
|
const ttyOutput: string[] = [];
|
||||||
|
const exitFrames: Array<number | null | undefined> = [];
|
||||||
|
const terminalErrors: string[] = [];
|
||||||
|
let closeCount = 0;
|
||||||
|
|
||||||
const socketTextFrames: string[] = [];
|
session.onReady((frame) => {
|
||||||
const socketBinaryFrames: string[] = [];
|
readyFrames.push(frame.processId);
|
||||||
ws.addEventListener("message", (event) => {
|
});
|
||||||
if (typeof event.data === "string") {
|
session.onData((bytes) => {
|
||||||
socketTextFrames.push(event.data);
|
ttyOutput.push(Buffer.from(bytes).toString("utf8"));
|
||||||
return;
|
});
|
||||||
}
|
session.onExit((frame) => {
|
||||||
socketBinaryFrames.push(decodeSocketPayload(event.data));
|
exitFrames.push(frame.exitCode);
|
||||||
|
});
|
||||||
|
session.onError((error) => {
|
||||||
|
terminalErrors.push(error instanceof Error ? error.message : error.message);
|
||||||
|
});
|
||||||
|
session.onClose(() => {
|
||||||
|
closeCount += 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => readyFrames[0]);
|
||||||
const ready = socketTextFrames.find((frame) => frame.includes('"type":"ready"'));
|
|
||||||
return ready;
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.send(JSON.stringify({
|
session.sendInput("hello tty\n");
|
||||||
type: "input",
|
|
||||||
data: "hello tty\n",
|
|
||||||
}));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const joined = socketBinaryFrames.join("");
|
const joined = ttyOutput.join("");
|
||||||
return joined.includes("hello tty") ? joined : undefined;
|
return joined.includes("hello tty") ? joined : undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.close();
|
session.close();
|
||||||
|
await session.closed;
|
||||||
|
expect(closeCount).toBeGreaterThan(0);
|
||||||
|
expect(exitFrames).toHaveLength(0);
|
||||||
|
expect(terminalErrors).toEqual([]);
|
||||||
|
|
||||||
await waitForAsync(async () => {
|
await waitForAsync(async () => {
|
||||||
const processInfo = await sdk.getProcess(ttyProcess.id);
|
const processInfo = await sdk.getProcess(ttyProcess.id);
|
||||||
return processInfo.status === "running" ? processInfo : undefined;
|
return processInfo.status === "running" ? processInfo : undefined;
|
||||||
|
|
|
||||||
|
|
@ -331,7 +331,10 @@ impl ProcessRuntime {
|
||||||
}
|
}
|
||||||
timeout_ms = timeout_ms.min(config.max_run_timeout_ms);
|
timeout_ms = timeout_ms.min(config.max_run_timeout_ms);
|
||||||
|
|
||||||
let max_output_bytes = spec.max_output_bytes.unwrap_or(config.max_output_bytes);
|
let max_output_bytes = spec
|
||||||
|
.max_output_bytes
|
||||||
|
.unwrap_or(config.max_output_bytes)
|
||||||
|
.min(config.max_output_bytes);
|
||||||
|
|
||||||
let mut cmd = Command::new(&spec.command);
|
let mut cmd = Command::new(&spec.command);
|
||||||
cmd.args(&spec.args)
|
cmd.args(&spec.args)
|
||||||
|
|
@ -343,6 +346,9 @@ impl ProcessRuntime {
|
||||||
cmd.current_dir(cwd);
|
cmd.current_dir(cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !spec.env.contains_key("TERM") {
|
||||||
|
cmd.env("TERM", "xterm-256color");
|
||||||
|
}
|
||||||
for (key, value) in &spec.env {
|
for (key, value) in &spec.env {
|
||||||
cmd.env(key, value);
|
cmd.env(key, value);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,38 @@ fn write_executable(path: &Path, script: &str) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_fake_npm(path: &Path) {
|
||||||
|
write_executable(
|
||||||
|
path,
|
||||||
|
r#"#!/usr/bin/env sh
|
||||||
|
set -e
|
||||||
|
prefix=""
|
||||||
|
while [ "$#" -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
install|--no-audit|--no-fund)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--prefix)
|
||||||
|
prefix="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -n "$prefix" ] || exit 1
|
||||||
|
mkdir -p "$prefix/node_modules/.bin"
|
||||||
|
for bin in claude-code-acp codex-acp amp-acp pi-acp cursor-agent-acp; do
|
||||||
|
echo '#!/usr/bin/env sh' > "$prefix/node_modules/.bin/$bin"
|
||||||
|
echo 'exit 0' >> "$prefix/node_modules/.bin/$bin"
|
||||||
|
chmod +x "$prefix/node_modules/.bin/$bin"
|
||||||
|
done
|
||||||
|
exit 0
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn serve_registry_once(document: Value) -> String {
|
fn serve_registry_once(document: Value) -> String {
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind registry server");
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind registry server");
|
||||||
let address = listener.local_addr().expect("registry address");
|
let address = listener.local_addr().expect("registry address");
|
||||||
|
|
|
||||||
|
|
@ -182,10 +182,7 @@ async fn lazy_install_runs_on_first_bootstrap() {
|
||||||
.expect("create agent processes dir");
|
.expect("create agent processes dir");
|
||||||
write_executable(&install_path.join("codex"), "#!/usr/bin/env sh\nexit 0\n");
|
write_executable(&install_path.join("codex"), "#!/usr/bin/env sh\nexit 0\n");
|
||||||
fs::create_dir_all(install_path.join("bin")).expect("create bin dir");
|
fs::create_dir_all(install_path.join("bin")).expect("create bin dir");
|
||||||
write_executable(
|
write_fake_npm(&install_path.join("bin").join("npm"));
|
||||||
&install_path.join("bin").join("npx"),
|
|
||||||
"#!/usr/bin/env sh\nwhile IFS= read -r _line; do :; done\n",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let original_path = std::env::var_os("PATH").unwrap_or_default();
|
let original_path = std::env::var_os("PATH").unwrap_or_default();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue