skydive-cli 0.1.0-beta.186 → 0.1.0-beta.199
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/js/bin.mjs
CHANGED
|
@@ -31,7 +31,7 @@ var __exportAll = (all, no_symbols) => {
|
|
|
31
31
|
|
|
32
32
|
//#endregion
|
|
33
33
|
//#region package.json
|
|
34
|
-
var version$1 = "0.1.0-beta.
|
|
34
|
+
var version$1 = "0.1.0-beta.199";
|
|
35
35
|
|
|
36
36
|
//#endregion
|
|
37
37
|
//#region src/types.ts
|
|
@@ -2152,7 +2152,7 @@ const chatCommand = {
|
|
|
2152
2152
|
printError(`Unknown theme "${themeId}". Valid themes: ${themes.map((t) => t.id).join(", ")}`);
|
|
2153
2153
|
process.exit(1);
|
|
2154
2154
|
}
|
|
2155
|
-
const { runChat } = await import("./boot-
|
|
2155
|
+
const { runChat } = await import("./boot-DGdoNlG2.mjs");
|
|
2156
2156
|
await runChat({
|
|
2157
2157
|
appUrl,
|
|
2158
2158
|
sessionToken: session.value.sessionToken,
|
|
@@ -3330,7 +3330,7 @@ const switchCommand = {
|
|
|
3330
3330
|
printError("The workspace picker needs the Bun runtime and it could not be set up automatically. Pass a workspace slug instead, or install Bun and retry.");
|
|
3331
3331
|
process.exit(1);
|
|
3332
3332
|
}
|
|
3333
|
-
const { runWorkspacePicker } = await import("./boot-
|
|
3333
|
+
const { runWorkspacePicker } = await import("./boot-DGdoNlG2.mjs");
|
|
3334
3334
|
await runWorkspacePicker(session);
|
|
3335
3335
|
return;
|
|
3336
3336
|
}
|
|
@@ -3457,4 +3457,4 @@ function resolveArgv(args, tty = {
|
|
|
3457
3457
|
createCli(resolveArgv(hideBin(process.argv))).parse();
|
|
3458
3458
|
|
|
3459
3459
|
//#endregion
|
|
3460
|
-
export {
|
|
3460
|
+
export { getSavedTheme as A, themesForMode as C, DEFAULT_API_URL as D, setActiveWorkspace as E, saveTheme as M, DEFAULT_APP_URL as O, themeVersion as S, listWorkspaces as T, noColorRequested as _, parseOauthConnectParams as a, themeMode as b, parseConnectCard as c, HttpError as d, createRestClient as f, monoTheme as g, findTheme as h, parseExternalOauthConnectParams as i, resolveWebUrl as j, getConfigPath as k, errorMessage as l, applyTheme as m, MASK_CHAR as n, reconcileMaskedInput as o, DEFAULT_THEME_ID as p, cardActionErrorMessage as r, resolveConnectUrl as s, resolveAgent as t, isRecord as u, theme as v, getActiveWorkspaceId as w, themeModeFromColorFgBg as x, themeForMode as y };
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { A as
|
|
2
|
+
import { A as getSavedTheme, C as themesForMode, D as DEFAULT_API_URL, E as setActiveWorkspace, M as saveTheme, O as DEFAULT_APP_URL, S as themeVersion, T as listWorkspaces, _ as noColorRequested, a as parseOauthConnectParams, b as themeMode, c as parseConnectCard, d as HttpError, f as createRestClient, g as monoTheme, h as findTheme, i as parseExternalOauthConnectParams, j as resolveWebUrl, k as getConfigPath, l as errorMessage, m as applyTheme, n as MASK_CHAR, o as reconcileMaskedInput, p as DEFAULT_THEME_ID, r as cardActionErrorMessage, s as resolveConnectUrl, t as resolveAgent, u as isRecord, v as theme, w as getActiveWorkspaceId, x as themeModeFromColorFgBg, y as themeForMode } from "./bin.mjs";
|
|
3
3
|
import path, { basename, isAbsolute, join, win32 } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import open from "open";
|
|
6
|
-
import { spawn } from "node:child_process";
|
|
6
|
+
import { execFile, spawn } from "node:child_process";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { constants } from "node:fs";
|
|
7
9
|
import { MarkdownRenderable, RenderableEvents, SyntaxStyle, createCliRenderer, decodePasteBytes, detectLinks } from "@opentui/core";
|
|
8
10
|
import { createRoot, extend, useKeyboard, usePaste, useRenderer, useTerminalDimensions } from "@opentui/react";
|
|
9
11
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
10
12
|
import { create } from "zustand";
|
|
11
13
|
import { WebSocket } from "ws";
|
|
14
|
+
import * as os$1 from "node:os";
|
|
12
15
|
import os, { homedir, platform, release, tmpdir } from "node:os";
|
|
13
16
|
import { createConnection } from "node:net";
|
|
14
|
-
import { appendFile, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
17
|
+
import { access, appendFile, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
15
18
|
import { Fragment, jsx, jsxs } from "@opentui/react/jsx-runtime";
|
|
16
19
|
import { fileURLToPath } from "node:url";
|
|
17
20
|
import { fileTypeFromBuffer } from "file-type";
|
|
@@ -59,6 +62,7 @@ const useStore = create((set, get) => ({
|
|
|
59
62
|
themeId: DEFAULT_THEME_ID.dark,
|
|
60
63
|
themeLocked: false,
|
|
61
64
|
appUrl: null,
|
|
65
|
+
sessionToken: null,
|
|
62
66
|
workspaceName: null,
|
|
63
67
|
rest: null,
|
|
64
68
|
promptHistoryPath: null,
|
|
@@ -95,8 +99,9 @@ const useStore = create((set, get) => ({
|
|
|
95
99
|
themeLocked: true
|
|
96
100
|
});
|
|
97
101
|
},
|
|
98
|
-
setClients: ({ appUrl, rest, promptHistoryPath }) => set({
|
|
102
|
+
setClients: ({ appUrl, sessionToken, rest, promptHistoryPath }) => set({
|
|
99
103
|
appUrl,
|
|
104
|
+
sessionToken,
|
|
100
105
|
rest,
|
|
101
106
|
promptHistoryPath
|
|
102
107
|
}),
|
|
@@ -531,7 +536,7 @@ var PortalClient = class {
|
|
|
531
536
|
this.refreshDevice();
|
|
532
537
|
});
|
|
533
538
|
ws.on("message", (data, isBinary) => {
|
|
534
|
-
if (isBinary) jobs.handleFrame(toBuffer(data));
|
|
539
|
+
if (isBinary) jobs.handleFrame(toBuffer$1(data));
|
|
535
540
|
});
|
|
536
541
|
ws.on("error", (err) => {
|
|
537
542
|
this.error = errorMessage(err);
|
|
@@ -582,7 +587,7 @@ var PortalClient = class {
|
|
|
582
587
|
});
|
|
583
588
|
}
|
|
584
589
|
};
|
|
585
|
-
function toBuffer(data) {
|
|
590
|
+
function toBuffer$1(data) {
|
|
586
591
|
if (Buffer.isBuffer(data)) return data;
|
|
587
592
|
if (Array.isArray(data)) return Buffer.concat(data);
|
|
588
593
|
return Buffer.from(data);
|
|
@@ -3073,6 +3078,44 @@ function parseMatches(output) {
|
|
|
3073
3078
|
return null;
|
|
3074
3079
|
}
|
|
3075
3080
|
|
|
3081
|
+
//#endregion
|
|
3082
|
+
//#region src/chat/tui/chunks/shell-local.tsx
|
|
3083
|
+
/**
|
|
3084
|
+
* Renders a `! <cmd>` local-shell escape: the command the user ran on their own
|
|
3085
|
+
* machine, followed by its combined stdout/stderr. Visually distinct from agent
|
|
3086
|
+
* tool calls (which run in the sandbox) — a `!` sigil colored by outcome (dim
|
|
3087
|
+
* while running, success/error once done), so a glance tells you this executed
|
|
3088
|
+
* locally, not on the agent.
|
|
3089
|
+
*/
|
|
3090
|
+
function ShellLocalItem({ item }) {
|
|
3091
|
+
const running = item.exit === null;
|
|
3092
|
+
const failed = item.exit !== null && item.exit !== 0;
|
|
3093
|
+
return /* @__PURE__ */ jsxs("box", {
|
|
3094
|
+
style: { flexDirection: "column" },
|
|
3095
|
+
children: [/* @__PURE__ */ jsxs("text", { children: [
|
|
3096
|
+
/* @__PURE__ */ jsx("span", {
|
|
3097
|
+
fg: running ? theme.dim : failed ? theme.error : theme.success,
|
|
3098
|
+
children: "! "
|
|
3099
|
+
}),
|
|
3100
|
+
/* @__PURE__ */ jsx("span", {
|
|
3101
|
+
fg: theme.tool,
|
|
3102
|
+
children: item.command
|
|
3103
|
+
}),
|
|
3104
|
+
running ? /* @__PURE__ */ jsx("span", {
|
|
3105
|
+
fg: theme.dim,
|
|
3106
|
+
children: " · running…"
|
|
3107
|
+
}) : null,
|
|
3108
|
+
failed ? /* @__PURE__ */ jsxs("span", {
|
|
3109
|
+
fg: theme.error,
|
|
3110
|
+
children: [" · exit ", item.exit]
|
|
3111
|
+
}) : null
|
|
3112
|
+
] }), item.output ? /* @__PURE__ */ jsx(Indented, { children: /* @__PURE__ */ jsx("text", {
|
|
3113
|
+
fg: theme.muted,
|
|
3114
|
+
children: item.output.replace(/\n+$/, "")
|
|
3115
|
+
}) }) : null]
|
|
3116
|
+
});
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3076
3119
|
//#endregion
|
|
3077
3120
|
//#region src/chat/tui/chunks/index.tsx
|
|
3078
3121
|
/**
|
|
@@ -3162,6 +3205,10 @@ function RenderItem({ item }) {
|
|
|
3162
3205
|
]
|
|
3163
3206
|
})
|
|
3164
3207
|
});
|
|
3208
|
+
case "shell": return /* @__PURE__ */ jsx(Row, {
|
|
3209
|
+
barColor: null,
|
|
3210
|
+
children: /* @__PURE__ */ jsx(ShellLocalItem, { item })
|
|
3211
|
+
});
|
|
3165
3212
|
}
|
|
3166
3213
|
}
|
|
3167
3214
|
function StreamingAssistantText({ text }) {
|
|
@@ -4661,6 +4708,568 @@ function ThemePicker({ onClose }) {
|
|
|
4661
4708
|
});
|
|
4662
4709
|
}
|
|
4663
4710
|
|
|
4711
|
+
//#endregion
|
|
4712
|
+
//#region src/chat/shell/snapshot.ts
|
|
4713
|
+
/**
|
|
4714
|
+
* Shell snapshot for the `!` local-shell escape.
|
|
4715
|
+
*
|
|
4716
|
+
* The chat composer runs on the user's own machine. When they type `! <cmd>`,
|
|
4717
|
+
* we want that command to see their real interactive-shell setup — aliases,
|
|
4718
|
+
* functions, shell options, PATH — the same way it would in their terminal.
|
|
4719
|
+
*
|
|
4720
|
+
* Aliases and functions live in ~/.zshrc / ~/.bashrc, which non-interactive
|
|
4721
|
+
* shells don't source (or source without alias expansion), and are never
|
|
4722
|
+
* exported to child processes. Re-sourcing the rc file on every command is slow
|
|
4723
|
+
* (100s of ms with nvm/rbenv/oh-my-zsh) and can hang on interactive prompts.
|
|
4724
|
+
*
|
|
4725
|
+
* So, once per session, we run the user's login shell, source their rc, and
|
|
4726
|
+
* serialize the resulting state into a standalone .sh file. Every `!` command
|
|
4727
|
+
* then sources that cheap file and runs. This mirrors the approach Claude Code
|
|
4728
|
+
* uses for its bash mode.
|
|
4729
|
+
*
|
|
4730
|
+
* The snapshot is cached on disk keyed by (shell, rc-file mtime), so it is
|
|
4731
|
+
* built once and reused across CLI sessions, and regenerated automatically when
|
|
4732
|
+
* the user edits their rc file. There is deliberately no `!!` sigil — a second
|
|
4733
|
+
* bang has a base shell meaning (history expansion) we do not override; the
|
|
4734
|
+
* agent-sandbox counterpart is the `/sandbox` slash command, not a sigil.
|
|
4735
|
+
*/
|
|
4736
|
+
const SNAPSHOT_CREATION_TIMEOUT_MS = 1e4;
|
|
4737
|
+
const SNAPSHOT_MAX_BUFFER = 1024 * 1024;
|
|
4738
|
+
/**
|
|
4739
|
+
* Resolve which shell the `!` escape uses. Order:
|
|
4740
|
+
* $SKYDIVE_SHELL (if it names bash/zsh and is executable)
|
|
4741
|
+
* $SHELL (if it names bash/zsh and is executable)
|
|
4742
|
+
* `zsh` / `bash` found on $PATH
|
|
4743
|
+
* `zsh` / `bash` in common install locations
|
|
4744
|
+
* /bin/sh as a last resort
|
|
4745
|
+
* Preference is bash-first when $SHELL mentions bash, else zsh-first — matching
|
|
4746
|
+
* the user's actual login shell where we can tell.
|
|
4747
|
+
*/
|
|
4748
|
+
async function resolveShell(env = process.env) {
|
|
4749
|
+
const override = env.SKYDIVE_SHELL;
|
|
4750
|
+
if (override && /bash|zsh/.test(override) && await isExecutable(override)) return describeShell(override);
|
|
4751
|
+
const shellEnv = env.SHELL;
|
|
4752
|
+
if (shellEnv && /bash|zsh/.test(shellEnv) && await isExecutable(shellEnv)) return describeShell(shellEnv);
|
|
4753
|
+
const candidates = !!shellEnv && shellEnv.includes("bash") ? ["bash", "zsh"] : ["zsh", "bash"];
|
|
4754
|
+
for (const name of candidates) {
|
|
4755
|
+
const found = await whichOnPath(name, env);
|
|
4756
|
+
if (found) return describeShell(found);
|
|
4757
|
+
}
|
|
4758
|
+
const searchDirs = [
|
|
4759
|
+
"/bin",
|
|
4760
|
+
"/usr/bin",
|
|
4761
|
+
"/usr/local/bin",
|
|
4762
|
+
"/opt/homebrew/bin",
|
|
4763
|
+
"/run/current-system/sw/bin"
|
|
4764
|
+
];
|
|
4765
|
+
for (const name of candidates) for (const dir of searchDirs) {
|
|
4766
|
+
const full = path.join(dir, name);
|
|
4767
|
+
if (await isExecutable(full)) return describeShell(full);
|
|
4768
|
+
}
|
|
4769
|
+
return describeShell("/bin/sh");
|
|
4770
|
+
}
|
|
4771
|
+
/**
|
|
4772
|
+
* Resolve `name` against $PATH, returning the first executable match, or
|
|
4773
|
+
* undefined. Pure filesystem lookup (no subprocess), matching `command -v`
|
|
4774
|
+
* semantics for a bare command name.
|
|
4775
|
+
*/
|
|
4776
|
+
async function whichOnPath(name, env) {
|
|
4777
|
+
const pathVar = env.PATH ?? "";
|
|
4778
|
+
if (!pathVar) return void 0;
|
|
4779
|
+
for (const dir of pathVar.split(path.delimiter)) {
|
|
4780
|
+
if (!dir) continue;
|
|
4781
|
+
const full = path.join(dir, name);
|
|
4782
|
+
if (await isExecutable(full)) return full;
|
|
4783
|
+
}
|
|
4784
|
+
}
|
|
4785
|
+
function describeShell(bin) {
|
|
4786
|
+
const kind = bin.includes("zsh") ? "zsh" : bin.includes("bash") ? "bash" : "sh";
|
|
4787
|
+
const rcName = kind === "zsh" ? ".zshrc" : kind === "bash" ? ".bashrc" : ".profile";
|
|
4788
|
+
return {
|
|
4789
|
+
bin,
|
|
4790
|
+
kind,
|
|
4791
|
+
rcFile: path.join(os$1.homedir(), rcName)
|
|
4792
|
+
};
|
|
4793
|
+
}
|
|
4794
|
+
async function isExecutable(p) {
|
|
4795
|
+
try {
|
|
4796
|
+
await access(p, constants.X_OK);
|
|
4797
|
+
return true;
|
|
4798
|
+
} catch {
|
|
4799
|
+
return false;
|
|
4800
|
+
}
|
|
4801
|
+
}
|
|
4802
|
+
/** Directory where cached snapshots live (next to the CLI's Conf store). */
|
|
4803
|
+
function snapshotsDir() {
|
|
4804
|
+
return path.join(path.dirname(getConfigPath()), "shell-snapshots");
|
|
4805
|
+
}
|
|
4806
|
+
/**
|
|
4807
|
+
* Cache key: the snapshot is valid as long as the shell binary and the rc
|
|
4808
|
+
* file's mtime are unchanged. Editing the rc regenerates on the next `!`.
|
|
4809
|
+
*/
|
|
4810
|
+
async function cacheKey(shell) {
|
|
4811
|
+
let rcMtime = "none";
|
|
4812
|
+
try {
|
|
4813
|
+
rcMtime = String((await stat(shell.rcFile)).mtimeMs);
|
|
4814
|
+
} catch {}
|
|
4815
|
+
return createHash("sha256").update(`${shell.bin}\0${shell.rcFile}\0${rcMtime}`).digest("hex").slice(0, 16);
|
|
4816
|
+
}
|
|
4817
|
+
function cachePath(shell, key) {
|
|
4818
|
+
return path.join(snapshotsDir(), `snapshot-${shell.kind}-${key}.sh`);
|
|
4819
|
+
}
|
|
4820
|
+
/**
|
|
4821
|
+
* Build the script we run inside the user's login shell to emit the snapshot.
|
|
4822
|
+
* Exported for tests — the generated script is the load-bearing surface.
|
|
4823
|
+
*/
|
|
4824
|
+
function buildSnapshotScript(shell, snapshotFilePath, rcFileExists, pathValue) {
|
|
4825
|
+
const isZsh = shell.kind === "zsh";
|
|
4826
|
+
const header = [
|
|
4827
|
+
`SNAPSHOT_FILE=${shQuote(snapshotFilePath)}`,
|
|
4828
|
+
rcFileExists ? `source ${shQuote(shell.rcFile)} < /dev/null` : "# no rc file to source",
|
|
4829
|
+
`echo "# skydive shell snapshot" >| "$SNAPSHOT_FILE"`,
|
|
4830
|
+
`echo "unalias -a 2>/dev/null || true" >> "$SNAPSHOT_FILE"`
|
|
4831
|
+
].join("\n");
|
|
4832
|
+
const functions = isZsh ? [
|
|
4833
|
+
`echo "# functions" >> "$SNAPSHOT_FILE"`,
|
|
4834
|
+
`typeset -f > /dev/null 2>&1`,
|
|
4835
|
+
`typeset +f | grep -vE '^_[^_]' | while read func; do`,
|
|
4836
|
+
` typeset -f "$func" >> "$SNAPSHOT_FILE"`,
|
|
4837
|
+
`done`
|
|
4838
|
+
].join("\n") : [
|
|
4839
|
+
`echo "# functions" >> "$SNAPSHOT_FILE"`,
|
|
4840
|
+
`declare -f > /dev/null 2>&1`,
|
|
4841
|
+
`declare -F | cut -d' ' -f3 | grep -vE '^_[^_]' | while read func; do`,
|
|
4842
|
+
` encoded_func=$(declare -f "$func" | base64)`,
|
|
4843
|
+
` echo "eval \\"\\$(echo '$encoded_func' | base64 -d)\\" > /dev/null 2>&1" >> "$SNAPSHOT_FILE"`,
|
|
4844
|
+
`done`
|
|
4845
|
+
].join("\n");
|
|
4846
|
+
const options = isZsh ? [`echo "# options" >> "$SNAPSHOT_FILE"`, `setopt | sed 's/^/setopt /' | head -n 1000 >> "$SNAPSHOT_FILE"`].join("\n") : [
|
|
4847
|
+
`echo "# options" >> "$SNAPSHOT_FILE"`,
|
|
4848
|
+
`shopt -p | head -n 1000 >> "$SNAPSHOT_FILE"`,
|
|
4849
|
+
`set -o | grep "on" | awk '{print "set -o " $1}' | head -n 1000 >> "$SNAPSHOT_FILE"`,
|
|
4850
|
+
`echo "shopt -s expand_aliases" >> "$SNAPSHOT_FILE"`
|
|
4851
|
+
].join("\n");
|
|
4852
|
+
const aliases = [`echo "# aliases" >> "$SNAPSHOT_FILE"`, `alias | sed 's/^alias //g' | sed 's/^/alias -- /' | head -n 1000 >> "$SNAPSHOT_FILE"`].join("\n");
|
|
4853
|
+
const pathPin = `echo "export PATH=${shQuote(pathValue)}" >> "$SNAPSHOT_FILE"`;
|
|
4854
|
+
const bashNoRcExpand = !rcFileExists && shell.kind === "bash" ? `echo "shopt -s expand_aliases" >> "$SNAPSHOT_FILE"` : "# rc present or non-bash";
|
|
4855
|
+
return [
|
|
4856
|
+
header,
|
|
4857
|
+
rcFileExists ? functions : "# no rc: skip functions",
|
|
4858
|
+
rcFileExists ? options : bashNoRcExpand,
|
|
4859
|
+
rcFileExists ? aliases : "# no rc: skip aliases",
|
|
4860
|
+
pathPin,
|
|
4861
|
+
`if [ ! -f "$SNAPSHOT_FILE" ]; then echo "snapshot not created" >&2; exit 1; fi`
|
|
4862
|
+
].join("\n\n");
|
|
4863
|
+
}
|
|
4864
|
+
/**
|
|
4865
|
+
* Ensure a valid snapshot exists and return its path, or undefined if creation
|
|
4866
|
+
* failed (soft failure — the caller falls back to a login shell). Reuses a
|
|
4867
|
+
* cached snapshot when the rc file is unchanged.
|
|
4868
|
+
*/
|
|
4869
|
+
async function ensureSnapshot(shell, env = process.env) {
|
|
4870
|
+
const out = cachePath(shell, await cacheKey(shell));
|
|
4871
|
+
try {
|
|
4872
|
+
await access(out);
|
|
4873
|
+
return out;
|
|
4874
|
+
} catch {}
|
|
4875
|
+
let rcFileExists = false;
|
|
4876
|
+
try {
|
|
4877
|
+
await access(shell.rcFile);
|
|
4878
|
+
rcFileExists = true;
|
|
4879
|
+
} catch {
|
|
4880
|
+
rcFileExists = false;
|
|
4881
|
+
}
|
|
4882
|
+
await mkdir(snapshotsDir(), { recursive: true });
|
|
4883
|
+
const script = buildSnapshotScript(shell, out, rcFileExists, env.PATH ?? "");
|
|
4884
|
+
if (!await new Promise((resolve) => {
|
|
4885
|
+
execFile(shell.bin, [
|
|
4886
|
+
"-c",
|
|
4887
|
+
"-l",
|
|
4888
|
+
script
|
|
4889
|
+
], {
|
|
4890
|
+
env: {
|
|
4891
|
+
...env,
|
|
4892
|
+
SHELL: shell.bin,
|
|
4893
|
+
GIT_EDITOR: "true"
|
|
4894
|
+
},
|
|
4895
|
+
timeout: SNAPSHOT_CREATION_TIMEOUT_MS,
|
|
4896
|
+
maxBuffer: SNAPSHOT_MAX_BUFFER,
|
|
4897
|
+
encoding: "utf8"
|
|
4898
|
+
}, (error) => resolve(!error));
|
|
4899
|
+
})) return void 0;
|
|
4900
|
+
try {
|
|
4901
|
+
await readFile(out, "utf8");
|
|
4902
|
+
return out;
|
|
4903
|
+
} catch {
|
|
4904
|
+
return;
|
|
4905
|
+
}
|
|
4906
|
+
}
|
|
4907
|
+
/** Shell-quote a single string for safe interpolation (single-quote wrap). */
|
|
4908
|
+
function shQuote(s) {
|
|
4909
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
4910
|
+
}
|
|
4911
|
+
|
|
4912
|
+
//#endregion
|
|
4913
|
+
//#region src/chat/shell/run.ts
|
|
4914
|
+
/**
|
|
4915
|
+
* Runs a single `! <cmd>` local-shell command with the user's snapshotted
|
|
4916
|
+
* shell environment (aliases / functions / options / PATH), and routes the
|
|
4917
|
+
* chat composer's raw input to the right destination.
|
|
4918
|
+
*
|
|
4919
|
+
* The per-command string is, joined by ` && `:
|
|
4920
|
+
*
|
|
4921
|
+
* source '<snapshot>' 2>/dev/null || true
|
|
4922
|
+
* shopt -u extglob 2>/dev/null || true # zsh: setopt NO_EXTENDED_GLOB
|
|
4923
|
+
* eval '<user command, shell-quoted>'
|
|
4924
|
+
* pwd -P >| '<cwd file>'
|
|
4925
|
+
*
|
|
4926
|
+
* Two details carry the whole feature:
|
|
4927
|
+
*
|
|
4928
|
+
* 1. `eval` is REQUIRED. A shell parses an entire line before executing any of
|
|
4929
|
+
* it, so aliases sourced on that same line are not yet available when the
|
|
4930
|
+
* line is parsed. Routing the command through `eval` forces a second parse
|
|
4931
|
+
* pass, at which point the aliases exist. `source snapshot && ll` would NOT
|
|
4932
|
+
* expand `ll`; `source snapshot && eval 'll'` does. This is the trick.
|
|
4933
|
+
*
|
|
4934
|
+
* 2. `pwd -P >| <file>`, read back after the process exits, is how cwd persists
|
|
4935
|
+
* across `!` commands — each command is a fresh process, so `! cd foo` then
|
|
4936
|
+
* `! ls` only works if we carry the cwd forward ourselves.
|
|
4937
|
+
*
|
|
4938
|
+
* When no snapshot could be built we fall back to a login shell (`-c -l`) so the
|
|
4939
|
+
* user at least gets their rc-driven environment, just slower.
|
|
4940
|
+
*/
|
|
4941
|
+
/** Create a session: resolve the shell and build/reuse its snapshot once. */
|
|
4942
|
+
async function createShellSession(env = process.env) {
|
|
4943
|
+
const shell = await resolveShell(env);
|
|
4944
|
+
return {
|
|
4945
|
+
shell,
|
|
4946
|
+
snapshotPath: await ensureSnapshot(shell, env),
|
|
4947
|
+
cwd: process.cwd()
|
|
4948
|
+
};
|
|
4949
|
+
}
|
|
4950
|
+
/**
|
|
4951
|
+
* Assemble the `-c` command string for one command. Exported for tests — the
|
|
4952
|
+
* `eval` placement is the load-bearing bit and must be asserted directly.
|
|
4953
|
+
*/
|
|
4954
|
+
function buildCommandString(session, command, cwdFile) {
|
|
4955
|
+
const parts = [];
|
|
4956
|
+
if (session.snapshotPath) parts.push(`source ${shQuote(session.snapshotPath)} 2>/dev/null || true`);
|
|
4957
|
+
parts.push(session.shell.kind === "zsh" ? "setopt NO_EXTENDED_GLOB 2>/dev/null || true" : "shopt -u extglob 2>/dev/null || true");
|
|
4958
|
+
parts.push(`eval ${shQuote(command)}`);
|
|
4959
|
+
parts.push(`pwd -P >| ${shQuote(cwdFile)}`);
|
|
4960
|
+
return parts.join(" && ");
|
|
4961
|
+
}
|
|
4962
|
+
/**
|
|
4963
|
+
* Spawn args for one command. Exported for tests. With a snapshot we spawn
|
|
4964
|
+
* NON-login (fast — the snapshot already carries env); without one, fall back
|
|
4965
|
+
* to a login shell so rc init still happens.
|
|
4966
|
+
*/
|
|
4967
|
+
function spawnArgs(session, commandString) {
|
|
4968
|
+
return session.snapshotPath ? ["-c", commandString] : [
|
|
4969
|
+
"-c",
|
|
4970
|
+
"-l",
|
|
4971
|
+
commandString
|
|
4972
|
+
];
|
|
4973
|
+
}
|
|
4974
|
+
/**
|
|
4975
|
+
* Run one command, streaming output via `onEvent`. Resolves with the exit code
|
|
4976
|
+
* once the process ends. Updates `session.cwd` from the persisted pwd so the
|
|
4977
|
+
* next command starts where this one left off.
|
|
4978
|
+
*/
|
|
4979
|
+
async function runShellCommand(session, command, onEvent, env = process.env) {
|
|
4980
|
+
const tmp = await mkdtemp(path.join(os$1.tmpdir(), "skydive-sh-"));
|
|
4981
|
+
const cwdFile = path.join(tmp, "cwd");
|
|
4982
|
+
const commandString = buildCommandString(session, command, cwdFile);
|
|
4983
|
+
return new Promise((resolve) => {
|
|
4984
|
+
const child = spawn(session.shell.bin, spawnArgs(session, commandString), {
|
|
4985
|
+
cwd: session.cwd,
|
|
4986
|
+
env: {
|
|
4987
|
+
...env,
|
|
4988
|
+
SHELL: session.shell.bin,
|
|
4989
|
+
GIT_EDITOR: "true"
|
|
4990
|
+
}
|
|
4991
|
+
});
|
|
4992
|
+
child.stdout.setEncoding("utf8");
|
|
4993
|
+
child.stderr.setEncoding("utf8");
|
|
4994
|
+
child.stdout.on("data", (chunk) => onEvent({
|
|
4995
|
+
type: "stdout",
|
|
4996
|
+
chunk
|
|
4997
|
+
}));
|
|
4998
|
+
child.stderr.on("data", (chunk) => onEvent({
|
|
4999
|
+
type: "stderr",
|
|
5000
|
+
chunk
|
|
5001
|
+
}));
|
|
5002
|
+
const finish = async (code, signal) => {
|
|
5003
|
+
try {
|
|
5004
|
+
const next = (await readFile(cwdFile, "utf8")).trim();
|
|
5005
|
+
if (next) session.cwd = next;
|
|
5006
|
+
} catch {}
|
|
5007
|
+
await rm(tmp, {
|
|
5008
|
+
recursive: true,
|
|
5009
|
+
force: true
|
|
5010
|
+
}).catch(() => {});
|
|
5011
|
+
onEvent({
|
|
5012
|
+
type: "exit",
|
|
5013
|
+
code,
|
|
5014
|
+
signal
|
|
5015
|
+
});
|
|
5016
|
+
resolve({
|
|
5017
|
+
code,
|
|
5018
|
+
signal
|
|
5019
|
+
});
|
|
5020
|
+
};
|
|
5021
|
+
child.on("error", () => void finish(null, null));
|
|
5022
|
+
child.on("close", (code, signal) => void finish(code, signal));
|
|
5023
|
+
});
|
|
5024
|
+
}
|
|
5025
|
+
function routeInput(raw) {
|
|
5026
|
+
const line = raw.trimEnd();
|
|
5027
|
+
if (line.startsWith("!")) {
|
|
5028
|
+
const command = line.slice(1).trim();
|
|
5029
|
+
return command ? {
|
|
5030
|
+
kind: "local-shell",
|
|
5031
|
+
command
|
|
5032
|
+
} : { kind: "noop" };
|
|
5033
|
+
}
|
|
5034
|
+
const sandbox = matchSlash(line, ["sandbox", "sbx"]);
|
|
5035
|
+
if (sandbox !== null) return sandbox ? {
|
|
5036
|
+
kind: "sandbox-exec",
|
|
5037
|
+
command: sandbox
|
|
5038
|
+
} : { kind: "sandbox-pty" };
|
|
5039
|
+
return {
|
|
5040
|
+
kind: "message",
|
|
5041
|
+
content: raw
|
|
5042
|
+
};
|
|
5043
|
+
}
|
|
5044
|
+
/**
|
|
5045
|
+
* If `line` is `/<name>` or `/<name> <rest>` for one of `names`, return the
|
|
5046
|
+
* trimmed rest (possibly ''); otherwise null. Case-insensitive on the name.
|
|
5047
|
+
*/
|
|
5048
|
+
function matchSlash(line, names) {
|
|
5049
|
+
if (!line.startsWith("/")) return null;
|
|
5050
|
+
const sp = line.indexOf(" ");
|
|
5051
|
+
const name = (sp === -1 ? line.slice(1) : line.slice(1, sp)).toLowerCase();
|
|
5052
|
+
if (!names.includes(name)) return null;
|
|
5053
|
+
return sp === -1 ? "" : line.slice(sp + 1).trim();
|
|
5054
|
+
}
|
|
5055
|
+
/** Max characters of `!` output we forward to the agent as context. */
|
|
5056
|
+
const SHELL_CONTEXT_OUTPUT_CAP = 16e3;
|
|
5057
|
+
/**
|
|
5058
|
+
* Format a finished `! <cmd>` run as a context message for the agent: just the
|
|
5059
|
+
* command and its output, as a fenced console block (Claude Code parity — `!`
|
|
5060
|
+
* output enters the model's context). Trailing whitespace is trimmed and very
|
|
5061
|
+
* long output is truncated from the FRONT (the tail — errors, results — is
|
|
5062
|
+
* usually the informative part; the head is often progress noise). A non-zero
|
|
5063
|
+
* exit is annotated on the prompt line so the agent notices the failure.
|
|
5064
|
+
*/
|
|
5065
|
+
function formatShellContext(command, output, exitCode) {
|
|
5066
|
+
let body = output.replace(/\s+$/, "");
|
|
5067
|
+
if (body.length > SHELL_CONTEXT_OUTPUT_CAP) body = `[… truncated to last ${SHELL_CONTEXT_OUTPUT_CAP} chars …]\n${body.slice(body.length - SHELL_CONTEXT_OUTPUT_CAP)}`;
|
|
5068
|
+
return [
|
|
5069
|
+
"```console",
|
|
5070
|
+
exitCode === 0 ? `$ ${command}` : `$ ${command} # exit ${exitCode}`,
|
|
5071
|
+
body,
|
|
5072
|
+
"```"
|
|
5073
|
+
].join("\n");
|
|
5074
|
+
}
|
|
5075
|
+
|
|
5076
|
+
//#endregion
|
|
5077
|
+
//#region src/chat/sandbox/client.ts
|
|
5078
|
+
/**
|
|
5079
|
+
* Client for the api's `/api/v1/sandbox/stream` WebSocket: relays a `/sandbox`
|
|
5080
|
+
* session into the agent's own sandbox — a live interactive PTY or a one-shot
|
|
5081
|
+
* streamed exec. Mirrors the transport shape of `portal/client.ts`, but dials a
|
|
5082
|
+
* different endpoint and speaks the sandbox-stream frame protocol (see the
|
|
5083
|
+
* server's sandbox-stream-ws.ts).
|
|
5084
|
+
*/
|
|
5085
|
+
const T = {
|
|
5086
|
+
DATA: 1,
|
|
5087
|
+
EXIT: 2,
|
|
5088
|
+
ERROR: 3,
|
|
5089
|
+
INPUT: 16,
|
|
5090
|
+
RESIZE: 17
|
|
5091
|
+
};
|
|
5092
|
+
function wsBase(appUrl) {
|
|
5093
|
+
const base = appUrl.replace(/\/+$/, "");
|
|
5094
|
+
if (base.startsWith("https://")) return `wss://${base.slice(8)}`;
|
|
5095
|
+
if (base.startsWith("http://")) return `ws://${base.slice(7)}`;
|
|
5096
|
+
return `wss://${base}`;
|
|
5097
|
+
}
|
|
5098
|
+
/**
|
|
5099
|
+
* A connected sandbox-stream session. Construct via `openSandboxStream`. Carries
|
|
5100
|
+
* the write side (keystrokes / resize for pty mode) and teardown.
|
|
5101
|
+
*/
|
|
5102
|
+
var SandboxStream = class SandboxStream {
|
|
5103
|
+
ws;
|
|
5104
|
+
closed = false;
|
|
5105
|
+
constructor(ws, onEvent) {
|
|
5106
|
+
this.ws = ws;
|
|
5107
|
+
ws.on("message", (data, isBinary) => {
|
|
5108
|
+
if (!isBinary) return;
|
|
5109
|
+
const buf = toBuffer(data);
|
|
5110
|
+
if (buf.length === 0) return;
|
|
5111
|
+
const body = buf.subarray(1);
|
|
5112
|
+
switch (buf[0]) {
|
|
5113
|
+
case T.DATA:
|
|
5114
|
+
onEvent({
|
|
5115
|
+
type: "data",
|
|
5116
|
+
bytes: new Uint8Array(body)
|
|
5117
|
+
});
|
|
5118
|
+
break;
|
|
5119
|
+
case T.EXIT:
|
|
5120
|
+
onEvent({
|
|
5121
|
+
type: "exit",
|
|
5122
|
+
code: body.length >= 4 ? body.readInt32BE(0) : 0
|
|
5123
|
+
});
|
|
5124
|
+
break;
|
|
5125
|
+
case T.ERROR:
|
|
5126
|
+
onEvent({
|
|
5127
|
+
type: "error",
|
|
5128
|
+
message: body.toString("utf8")
|
|
5129
|
+
});
|
|
5130
|
+
break;
|
|
5131
|
+
}
|
|
5132
|
+
});
|
|
5133
|
+
ws.on("close", () => {
|
|
5134
|
+
this.closed = true;
|
|
5135
|
+
onEvent({ type: "close" });
|
|
5136
|
+
});
|
|
5137
|
+
ws.on("error", () => {});
|
|
5138
|
+
}
|
|
5139
|
+
/** Feed keystroke bytes to the pty stdin. */
|
|
5140
|
+
sendInput(data) {
|
|
5141
|
+
if (this.closed || this.ws.readyState !== WebSocket.OPEN) return;
|
|
5142
|
+
this.ws.send(Buffer.concat([Buffer.from([T.INPUT]), Buffer.from(data)]));
|
|
5143
|
+
}
|
|
5144
|
+
/** Notify the pty of a terminal resize. */
|
|
5145
|
+
resize(cols, rows) {
|
|
5146
|
+
if (this.closed || this.ws.readyState !== WebSocket.OPEN) return;
|
|
5147
|
+
const b = Buffer.allocUnsafe(5);
|
|
5148
|
+
b[0] = T.RESIZE;
|
|
5149
|
+
b.writeUInt16BE(cols & 65535, 1);
|
|
5150
|
+
b.writeUInt16BE(rows & 65535, 3);
|
|
5151
|
+
this.ws.send(b);
|
|
5152
|
+
}
|
|
5153
|
+
close() {
|
|
5154
|
+
this.closed = true;
|
|
5155
|
+
this.ws.close();
|
|
5156
|
+
}
|
|
5157
|
+
/** Open a stream. `mode` is 'pty' (interactive) or 'exec' (one-shot). */
|
|
5158
|
+
static open(opts) {
|
|
5159
|
+
const url = new URL(`${wsBase(opts.appUrl)}/api/v1/sandbox/stream`);
|
|
5160
|
+
url.searchParams.set("agentId", opts.agentId);
|
|
5161
|
+
url.searchParams.set("mode", opts.mode);
|
|
5162
|
+
if (opts.mode === "pty") {
|
|
5163
|
+
url.searchParams.set("cols", String(opts.cols));
|
|
5164
|
+
url.searchParams.set("rows", String(opts.rows));
|
|
5165
|
+
} else url.searchParams.set("command", opts.command);
|
|
5166
|
+
return new SandboxStream(new WebSocket(url.toString(), { headers: { authorization: `Bearer ${opts.sessionToken}` } }), opts.onEvent);
|
|
5167
|
+
}
|
|
5168
|
+
};
|
|
5169
|
+
function toBuffer(data) {
|
|
5170
|
+
if (Buffer.isBuffer(data)) return data;
|
|
5171
|
+
if (Array.isArray(data)) return Buffer.concat(data);
|
|
5172
|
+
return Buffer.from(data);
|
|
5173
|
+
}
|
|
5174
|
+
|
|
5175
|
+
//#endregion
|
|
5176
|
+
//#region src/chat/sandbox/pty-session.ts
|
|
5177
|
+
/**
|
|
5178
|
+
* Runs a live interactive PTY into the agent's sandbox as a full-screen raw
|
|
5179
|
+
* terminal, by SUSPENDING the opentui renderer for the duration and doing a
|
|
5180
|
+
* direct byte passthrough:
|
|
5181
|
+
*
|
|
5182
|
+
* local stdin → INPUT frames → sandbox pty
|
|
5183
|
+
* sandbox pty → DATA frames → local stdout
|
|
5184
|
+
*
|
|
5185
|
+
* This is the correct shape for a real terminal: we don't reimplement a
|
|
5186
|
+
* terminal emulator, we hand the actual TTY to the remote shell. On exit
|
|
5187
|
+
* (the shell exits, the socket drops, or the user hits the detach key) we
|
|
5188
|
+
* restore the terminal and resume the TUI.
|
|
5189
|
+
*
|
|
5190
|
+
* Detach key: Ctrl-] (0x1d), the classic telnet/ssh escape — leaves the shell
|
|
5191
|
+
* running server-side is NOT a goal here; detaching closes the session.
|
|
5192
|
+
*/
|
|
5193
|
+
const DETACH_BYTE = 29;
|
|
5194
|
+
async function runPtySession(opts) {
|
|
5195
|
+
const { renderer } = opts;
|
|
5196
|
+
const stdin = renderer.stdin;
|
|
5197
|
+
const stdout = process.stdout;
|
|
5198
|
+
const size = () => ({
|
|
5199
|
+
cols: stdout.columns ?? 80,
|
|
5200
|
+
rows: stdout.rows ?? 24
|
|
5201
|
+
});
|
|
5202
|
+
renderer.suspend();
|
|
5203
|
+
return await new Promise((resolve) => {
|
|
5204
|
+
let settled = false;
|
|
5205
|
+
const initial = size();
|
|
5206
|
+
const stream = SandboxStream.open({
|
|
5207
|
+
mode: "pty",
|
|
5208
|
+
appUrl: opts.appUrl,
|
|
5209
|
+
sessionToken: opts.sessionToken,
|
|
5210
|
+
agentId: opts.agentId,
|
|
5211
|
+
cols: initial.cols,
|
|
5212
|
+
rows: initial.rows,
|
|
5213
|
+
onEvent: (e) => {
|
|
5214
|
+
switch (e.type) {
|
|
5215
|
+
case "data":
|
|
5216
|
+
stdout.write(e.bytes);
|
|
5217
|
+
break;
|
|
5218
|
+
case "error":
|
|
5219
|
+
stdout.write(`\r\n\x1b[31m${e.message}\x1b[0m\r\n`);
|
|
5220
|
+
finish({
|
|
5221
|
+
reason: "error",
|
|
5222
|
+
code: 1
|
|
5223
|
+
});
|
|
5224
|
+
break;
|
|
5225
|
+
case "exit":
|
|
5226
|
+
finish({
|
|
5227
|
+
reason: "exit",
|
|
5228
|
+
code: e.code
|
|
5229
|
+
});
|
|
5230
|
+
break;
|
|
5231
|
+
case "close":
|
|
5232
|
+
finish({
|
|
5233
|
+
reason: "exit",
|
|
5234
|
+
code: 0
|
|
5235
|
+
});
|
|
5236
|
+
break;
|
|
5237
|
+
}
|
|
5238
|
+
}
|
|
5239
|
+
});
|
|
5240
|
+
const onStdin = (chunk) => {
|
|
5241
|
+
if (chunk.length === 1 && chunk[0] === DETACH_BYTE) {
|
|
5242
|
+
finish({
|
|
5243
|
+
reason: "detach",
|
|
5244
|
+
code: 0
|
|
5245
|
+
});
|
|
5246
|
+
return;
|
|
5247
|
+
}
|
|
5248
|
+
stream.sendInput(new Uint8Array(chunk));
|
|
5249
|
+
};
|
|
5250
|
+
const onResize = () => {
|
|
5251
|
+
const s = size();
|
|
5252
|
+
stream.resize(s.cols, s.rows);
|
|
5253
|
+
};
|
|
5254
|
+
const wasRaw = stdin.isRaw ?? false;
|
|
5255
|
+
stdin.setRawMode?.(true);
|
|
5256
|
+
stdin.resume();
|
|
5257
|
+
stdin.on("data", onStdin);
|
|
5258
|
+
stdout.on("resize", onResize);
|
|
5259
|
+
stream.resize(initial.cols, initial.rows);
|
|
5260
|
+
function finish(result) {
|
|
5261
|
+
if (settled) return;
|
|
5262
|
+
settled = true;
|
|
5263
|
+
stdin.off("data", onStdin);
|
|
5264
|
+
stdout.off("resize", onResize);
|
|
5265
|
+
stdin.setRawMode?.(wasRaw);
|
|
5266
|
+
stream.close();
|
|
5267
|
+
renderer.resume();
|
|
5268
|
+
resolve(result);
|
|
5269
|
+
}
|
|
5270
|
+
});
|
|
5271
|
+
}
|
|
5272
|
+
|
|
4664
5273
|
//#endregion
|
|
4665
5274
|
//#region src/chat/tui/screens/chat.tsx
|
|
4666
5275
|
/**
|
|
@@ -4723,6 +5332,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
4723
5332
|
const rest = useStore((s) => s.rest);
|
|
4724
5333
|
const history = usePromptHistory({ filePath: useStore((s) => s.promptHistoryPath) });
|
|
4725
5334
|
const appUrl = useStore((s) => s.appUrl);
|
|
5335
|
+
const sessionToken = useStore((s) => s.sessionToken);
|
|
4726
5336
|
const goTo = useStore((s) => s.goTo);
|
|
4727
5337
|
const setChatTitle = useStore((s) => s.setChatTitle);
|
|
4728
5338
|
const portal = useStore((s) => s.portal);
|
|
@@ -4756,6 +5366,7 @@ function ChatScreen({ agent, conversation }) {
|
|
|
4756
5366
|
inputRef.current = input;
|
|
4757
5367
|
const credPromptRef = useRef(credPrompt);
|
|
4758
5368
|
credPromptRef.current = credPrompt;
|
|
5369
|
+
const shellSessionRef = useRef(null);
|
|
4759
5370
|
const hostState = grantPrompt || credPrompt ? "blocked" : run.kind === "idle" ? "idle" : "working";
|
|
4760
5371
|
useEffect(() => {
|
|
4761
5372
|
agentHost.transition({ state: hostState });
|
|
@@ -4907,12 +5518,13 @@ function ChatScreen({ agent, conversation }) {
|
|
|
4907
5518
|
cancelled = true;
|
|
4908
5519
|
};
|
|
4909
5520
|
}, []);
|
|
4910
|
-
const sendContent = useCallback(async (content, attachments) => {
|
|
5521
|
+
const sendContent = useCallback(async (content, attachments, opts) => {
|
|
4911
5522
|
if (!rest) return;
|
|
4912
5523
|
const trimmed = content.trim();
|
|
4913
5524
|
if (!trimmed && attachments.length === 0) return;
|
|
5525
|
+
const echo = opts?.echo !== false;
|
|
4914
5526
|
const optimisticId = crypto.randomUUID();
|
|
4915
|
-
setItems((prev) => [...prev, {
|
|
5527
|
+
if (echo) setItems((prev) => [...prev, {
|
|
4916
5528
|
kind: "user",
|
|
4917
5529
|
id: optimisticId,
|
|
4918
5530
|
text: userItemText(trimmed, attachments)
|
|
@@ -4946,8 +5558,10 @@ function ChatScreen({ agent, conversation }) {
|
|
|
4946
5558
|
id: crypto.randomUUID(),
|
|
4947
5559
|
text: errorMessage(err)
|
|
4948
5560
|
}]);
|
|
4949
|
-
|
|
4950
|
-
|
|
5561
|
+
if (echo) {
|
|
5562
|
+
setInput((existing) => existing ? existing : trimmed);
|
|
5563
|
+
setPending((prev) => prev.length > 0 ? prev : attachments);
|
|
5564
|
+
}
|
|
4951
5565
|
if (!wasStreaming) setRun({ kind: "idle" });
|
|
4952
5566
|
}
|
|
4953
5567
|
}, [
|
|
@@ -5139,10 +5753,118 @@ function ChatScreen({ agent, conversation }) {
|
|
|
5139
5753
|
}
|
|
5140
5754
|
})();
|
|
5141
5755
|
}, [rest, updateCard]);
|
|
5756
|
+
const runLocalShell = useCallback(async (command) => {
|
|
5757
|
+
const id = crypto.randomUUID();
|
|
5758
|
+
setItems((prev) => [...prev, {
|
|
5759
|
+
kind: "shell",
|
|
5760
|
+
id,
|
|
5761
|
+
command,
|
|
5762
|
+
output: "",
|
|
5763
|
+
exit: null
|
|
5764
|
+
}]);
|
|
5765
|
+
let captured = "";
|
|
5766
|
+
const appendOutput = (chunk) => {
|
|
5767
|
+
captured += chunk;
|
|
5768
|
+
setItems((prev) => prev.map((m) => m.id === id && m.kind === "shell" ? {
|
|
5769
|
+
...m,
|
|
5770
|
+
output: m.output + chunk
|
|
5771
|
+
} : m));
|
|
5772
|
+
};
|
|
5773
|
+
let exitCode;
|
|
5774
|
+
try {
|
|
5775
|
+
if (!shellSessionRef.current) shellSessionRef.current = await createShellSession();
|
|
5776
|
+
const { code } = await runShellCommand(shellSessionRef.current, command, (e) => {
|
|
5777
|
+
if (e.type === "stdout" || e.type === "stderr") appendOutput(e.chunk);
|
|
5778
|
+
});
|
|
5779
|
+
exitCode = code ?? 1;
|
|
5780
|
+
setItems((prev) => prev.map((m) => m.id === id && m.kind === "shell" ? {
|
|
5781
|
+
...m,
|
|
5782
|
+
exit: exitCode
|
|
5783
|
+
} : m));
|
|
5784
|
+
} catch (err) {
|
|
5785
|
+
appendOutput(`\n${errorMessage(err)}`);
|
|
5786
|
+
exitCode = 1;
|
|
5787
|
+
setItems((prev) => prev.map((m) => m.id === id && m.kind === "shell" ? {
|
|
5788
|
+
...m,
|
|
5789
|
+
exit: 1
|
|
5790
|
+
} : m));
|
|
5791
|
+
}
|
|
5792
|
+
sendContent(formatShellContext(command, captured, exitCode), [], { echo: false });
|
|
5793
|
+
}, [sendContent]);
|
|
5794
|
+
const runSandboxExec = useCallback((command) => {
|
|
5795
|
+
if (!appUrl || !sessionToken) return;
|
|
5796
|
+
const id = crypto.randomUUID();
|
|
5797
|
+
setItems((prev) => [...prev, {
|
|
5798
|
+
kind: "shell",
|
|
5799
|
+
id,
|
|
5800
|
+
command: `☁ ${command}`,
|
|
5801
|
+
output: "",
|
|
5802
|
+
exit: null
|
|
5803
|
+
}]);
|
|
5804
|
+
const dec = new TextDecoder();
|
|
5805
|
+
const append = (chunk) => setItems((prev) => prev.map((m) => m.id === id && m.kind === "shell" ? {
|
|
5806
|
+
...m,
|
|
5807
|
+
output: m.output + chunk
|
|
5808
|
+
} : m));
|
|
5809
|
+
const stamp = (exit) => setItems((prev) => prev.map((m) => m.id === id && m.kind === "shell" ? {
|
|
5810
|
+
...m,
|
|
5811
|
+
exit
|
|
5812
|
+
} : m));
|
|
5813
|
+
SandboxStream.open({
|
|
5814
|
+
mode: "exec",
|
|
5815
|
+
appUrl,
|
|
5816
|
+
sessionToken,
|
|
5817
|
+
agentId: agent.id,
|
|
5818
|
+
command,
|
|
5819
|
+
onEvent: (e) => {
|
|
5820
|
+
if (e.type === "data") append(dec.decode(e.bytes));
|
|
5821
|
+
else if (e.type === "error") {
|
|
5822
|
+
append(`\n${e.message}`);
|
|
5823
|
+
stamp(1);
|
|
5824
|
+
} else if (e.type === "exit") stamp(e.code);
|
|
5825
|
+
}
|
|
5826
|
+
});
|
|
5827
|
+
}, [
|
|
5828
|
+
appUrl,
|
|
5829
|
+
sessionToken,
|
|
5830
|
+
agent.id
|
|
5831
|
+
]);
|
|
5832
|
+
const runSandboxPty = useCallback(async () => {
|
|
5833
|
+
if (!appUrl || !sessionToken) return;
|
|
5834
|
+
const result = await runPtySession({
|
|
5835
|
+
renderer,
|
|
5836
|
+
appUrl,
|
|
5837
|
+
sessionToken,
|
|
5838
|
+
agentId: agent.id
|
|
5839
|
+
});
|
|
5840
|
+
setItems((prev) => [...prev, {
|
|
5841
|
+
kind: "shell",
|
|
5842
|
+
id: crypto.randomUUID(),
|
|
5843
|
+
command: "☁ sandbox terminal",
|
|
5844
|
+
output: result.reason === "error" ? "terminal ended with an error" : result.reason === "detach" ? "detached" : "session ended",
|
|
5845
|
+
exit: result.code
|
|
5846
|
+
}]);
|
|
5847
|
+
}, [
|
|
5848
|
+
appUrl,
|
|
5849
|
+
sessionToken,
|
|
5850
|
+
agent.id,
|
|
5851
|
+
renderer
|
|
5852
|
+
]);
|
|
5142
5853
|
const submit = useCallback(() => {
|
|
5143
5854
|
const content = input.trim();
|
|
5144
5855
|
const attachments = pending;
|
|
5145
5856
|
if (!content && attachments.length === 0) return;
|
|
5857
|
+
const routed = routeInput(content);
|
|
5858
|
+
if (routed.kind !== "message") {
|
|
5859
|
+
history.append(content);
|
|
5860
|
+
setInput("");
|
|
5861
|
+
composerRef.current?.clear();
|
|
5862
|
+
setComposerRows(1);
|
|
5863
|
+
if (routed.kind === "local-shell") runLocalShell(routed.command);
|
|
5864
|
+
else if (routed.kind === "sandbox-exec") runSandboxExec(routed.command);
|
|
5865
|
+
else if (routed.kind === "sandbox-pty") runSandboxPty();
|
|
5866
|
+
return;
|
|
5867
|
+
}
|
|
5146
5868
|
history.append(content);
|
|
5147
5869
|
setInput("");
|
|
5148
5870
|
composerRef.current?.clear();
|
|
@@ -5153,7 +5875,10 @@ function ChatScreen({ agent, conversation }) {
|
|
|
5153
5875
|
input,
|
|
5154
5876
|
pending,
|
|
5155
5877
|
sendContent,
|
|
5156
|
-
history
|
|
5878
|
+
history,
|
|
5879
|
+
runLocalShell,
|
|
5880
|
+
runSandboxExec,
|
|
5881
|
+
runSandboxPty
|
|
5157
5882
|
]);
|
|
5158
5883
|
const handleComposerChange = useCallback(() => {
|
|
5159
5884
|
const composer = composerRef.current;
|
|
@@ -5844,6 +6569,7 @@ function App({ appUrl, sessionToken, shareMachine, promptHistoryPath, notificati
|
|
|
5844
6569
|
});
|
|
5845
6570
|
setClients({
|
|
5846
6571
|
appUrl,
|
|
6572
|
+
sessionToken,
|
|
5847
6573
|
rest,
|
|
5848
6574
|
promptHistoryPath
|
|
5849
6575
|
});
|