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.186";
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-BEMmVR8C.mjs");
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-BEMmVR8C.mjs");
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 { resolveWebUrl as A, themesForMode as C, DEFAULT_API_URL as D, setActiveWorkspace as E, 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, saveTheme as j, getSavedTheme 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 };
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 resolveWebUrl, C as themesForMode, D as DEFAULT_API_URL, E as setActiveWorkspace, 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 saveTheme, k as getSavedTheme, 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";
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
- setInput((existing) => existing ? existing : trimmed);
4950
- setPending((prev) => prev.length > 0 ? prev : attachments);
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skydive-cli",
3
- "version": "0.1.0-beta.186",
3
+ "version": "0.1.0-beta.199",
4
4
  "description": "Skydive CLI — manage AI agents from the command line",
5
5
  "homepage": "https://skydive.com",
6
6
  "license": "MIT",