dsh-coding-sidebar 1.0.7 → 1.0.8
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/README.md +2 -2
- package/lib/client-editor.js +436 -227
- package/lib/client-registry.js +627 -7391
- package/lib/client-terminal.js +234 -182
- package/lib/client.js +634 -7398
- package/lib/index.js +367 -67
- package/lib/types/agent-pty.d.ts +62 -4
- package/lib/types/bundle-route.d.ts +1 -1
- package/lib/types/client/EditorHost.d.ts +1 -1
- package/lib/types/client/FileTree.d.ts +2 -2
- package/lib/types/client/TreePanel.d.ts +1 -1
- package/lib/types/client/chunk-loader.d.ts +1 -1
- package/lib/types/client/chunks/locale.d.ts +3 -0
- package/lib/types/client/conversation-draft.d.ts +85 -4
- package/lib/types/client/locales.d.ts +1 -20
- package/lib/types/client/selection-popup.d.ts +58 -0
- package/lib/types/client/service.d.ts +1 -1
- package/lib/types/client/terminal-font.d.ts +25 -2
- package/lib/types/context-types.d.ts +3 -1
- package/lib/types/index.d.ts +9 -0
- package/lib/types/prefs-shared.d.ts +7 -5
- package/lib/types/pty-manager.d.ts +53 -0
- package/lib/types/wire.d.ts +6 -2
- package/package.json +1 -1
- package/src/agent-pty.ts +221 -33
- package/src/bundle-route.ts +1 -1
- package/src/client/EditorHost.tsx +1 -1
- package/src/client/FileTree.tsx +4 -4
- package/src/client/Sidebar.tsx +41 -22
- package/src/client/TerminalView.tsx +13 -0
- package/src/client/TextEditor.tsx +27 -45
- package/src/client/TreePanel.tsx +16 -1
- package/src/client/chunk-loader.ts +1 -1
- package/src/client/chunks/locale.tsx +60 -0
- package/src/client/conversation-draft.ts +233 -7
- package/src/client/index.tsx +19 -8
- package/src/client/locales-ar.ts +5 -4
- package/src/client/locales-de.ts +5 -4
- package/src/client/locales-fr.ts +5 -4
- package/src/client/locales-hi.ts +5 -4
- package/src/client/locales-id.ts +5 -4
- package/src/client/locales-it.ts +5 -4
- package/src/client/locales-ja.ts +5 -4
- package/src/client/locales-ko.ts +5 -4
- package/src/client/locales-nl.ts +5 -4
- package/src/client/locales-pl.ts +5 -4
- package/src/client/locales-pt.ts +5 -4
- package/src/client/locales-ru.ts +5 -4
- package/src/client/locales-sv.ts +5 -4
- package/src/client/locales-th.ts +5 -4
- package/src/client/locales-tr.ts +5 -4
- package/src/client/locales-vi.ts +5 -4
- package/src/client/locales-zh-HK.ts +5 -4
- package/src/client/locales-zh-MO.ts +5 -4
- package/src/client/locales-zh-TW.ts +5 -4
- package/src/client/locales.ts +17 -51
- package/src/client/selection-popup.ts +155 -0
- package/src/client/service.ts +1 -1
- package/src/client/state.ts +14 -7
- package/src/client/terminal-font.ts +34 -3
- package/src/context-types.ts +3 -2
- package/src/git.ts +17 -1
- package/src/index.ts +39 -6
- package/src/open-external.ts +3 -4
- package/src/prefs-shared.ts +7 -5
- package/src/pty-manager.ts +176 -5
- package/src/sidechat-routes.ts +13 -1
- package/src/tools.ts +24 -6
- package/src/wire.ts +3 -0
package/lib/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { mkdir, open, opendir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
-
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
|
|
4
4
|
import { WebSocket, WebSocketServer } from "ws";
|
|
5
5
|
import z from "schemastery";
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
@@ -13,6 +13,7 @@ import { homedir, userInfo } from "node:os";
|
|
|
13
13
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
14
14
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
15
15
|
import { snapshotSubagentDescriptor } from "@deepseek-ai/dsh-subagent";
|
|
16
|
+
import { SessionLogOffset } from "@deepseek-ai/dsh-session";
|
|
16
17
|
//#region src/prefs-shared.ts
|
|
17
18
|
/**
|
|
18
19
|
* Shared "Side card" preference vocabulary (types + constants), consumed by
|
|
@@ -125,10 +126,12 @@ const PrefsSchema = z.object({
|
|
|
125
126
|
var SidebarError = class extends Error {
|
|
126
127
|
code;
|
|
127
128
|
status;
|
|
128
|
-
|
|
129
|
+
meta;
|
|
130
|
+
constructor(code, message, status = 400, meta) {
|
|
129
131
|
super(message);
|
|
130
132
|
this.code = code;
|
|
131
133
|
this.status = status;
|
|
134
|
+
this.meta = meta;
|
|
132
135
|
}
|
|
133
136
|
};
|
|
134
137
|
/** Body size bound of one JSON request (defense against unbounded reads). */
|
|
@@ -632,7 +635,11 @@ function isTrustedApiRequest(request, trustedHosts) {
|
|
|
632
635
|
* only allowlisted chunk names are servable (no path traversal).
|
|
633
636
|
*/
|
|
634
637
|
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
|
|
635
|
-
const CHUNK_NAMES = [
|
|
638
|
+
const CHUNK_NAMES = [
|
|
639
|
+
"terminal",
|
|
640
|
+
"editor",
|
|
641
|
+
"locale"
|
|
642
|
+
];
|
|
636
643
|
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
|
|
637
644
|
const LIB_DIR = dirname(fileURLToPath(import.meta.url));
|
|
638
645
|
/** sha1 content hash shortened to 12 hex chars (same shape as the client-modules rev). */
|
|
@@ -747,7 +754,7 @@ function revealCommand(path, platform = process.platform) {
|
|
|
747
754
|
};
|
|
748
755
|
case "win32": return {
|
|
749
756
|
command: "explorer.exe",
|
|
750
|
-
args: [
|
|
757
|
+
args: [`/select,${path}`]
|
|
751
758
|
};
|
|
752
759
|
default: return {
|
|
753
760
|
command: "xdg-open",
|
|
@@ -1060,16 +1067,37 @@ function pathIdentity(path) {
|
|
|
1060
1067
|
const absolute = resolve(path).replace(/[\\/]+$/, "");
|
|
1061
1068
|
return process.platform === "win32" ? absolute.toLowerCase() : absolute;
|
|
1062
1069
|
}
|
|
1070
|
+
/** Whether the current Git binary supports NUL-framed `worktree list` output.
|
|
1071
|
+
* Git < 2.36 rejects `-z`; cache the capability after the first attempt so
|
|
1072
|
+
* the SCM panel's polling does not repeatedly spawn a command known to fail. */
|
|
1073
|
+
let worktreeListSupportsZ;
|
|
1063
1074
|
/** Raw usable checkout records, shared by inventory and target validation.
|
|
1064
1075
|
* Prunable records point at missing paths and are deliberately excluded from
|
|
1065
1076
|
* both the selector and the command-target allowlist. */
|
|
1066
1077
|
async function listedWorktrees(cwd) {
|
|
1067
|
-
|
|
1078
|
+
let raw;
|
|
1079
|
+
if (worktreeListSupportsZ === false) raw = await runGit(cwd, [
|
|
1068
1080
|
"worktree",
|
|
1069
1081
|
"list",
|
|
1070
|
-
"--porcelain"
|
|
1071
|
-
|
|
1072
|
-
|
|
1082
|
+
"--porcelain"
|
|
1083
|
+
]);
|
|
1084
|
+
else try {
|
|
1085
|
+
raw = await runGit(cwd, [
|
|
1086
|
+
"worktree",
|
|
1087
|
+
"list",
|
|
1088
|
+
"--porcelain",
|
|
1089
|
+
"-z"
|
|
1090
|
+
]);
|
|
1091
|
+
worktreeListSupportsZ = true;
|
|
1092
|
+
} catch {
|
|
1093
|
+
worktreeListSupportsZ = false;
|
|
1094
|
+
raw = await runGit(cwd, [
|
|
1095
|
+
"worktree",
|
|
1096
|
+
"list",
|
|
1097
|
+
"--porcelain"
|
|
1098
|
+
]);
|
|
1099
|
+
}
|
|
1100
|
+
return parseWorktreeList(raw).filter((entry) => !entry.prunable);
|
|
1073
1101
|
}
|
|
1074
1102
|
/** All linked checkouts of the repository containing `cwd`, enriched with a
|
|
1075
1103
|
* live change count. The current checkout is first so a single-worktree repo
|
|
@@ -1484,7 +1512,7 @@ var PtyManager = class {
|
|
|
1484
1512
|
sessionId,
|
|
1485
1513
|
tabId,
|
|
1486
1514
|
cwd,
|
|
1487
|
-
pty: this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
|
|
1515
|
+
pty: this.nodePty.spawn(resolveShellExecutable(shell ?? this.shell), shellSpawnArgs(shellArgs ?? this.shellArgs), {
|
|
1488
1516
|
name: "xterm-256color",
|
|
1489
1517
|
cols: Math.max(2, Math.floor(cols)),
|
|
1490
1518
|
rows: Math.max(2, Math.floor(rows)),
|
|
@@ -1571,6 +1599,15 @@ var PtyManager = class {
|
|
|
1571
1599
|
for (const key of [...this.sessions.keys()]) this.close(key);
|
|
1572
1600
|
}
|
|
1573
1601
|
};
|
|
1602
|
+
/** Read one Windows environment value case-insensitively. Real
|
|
1603
|
+
* `process.env` has case-insensitive lookup on Windows, but injected objects
|
|
1604
|
+
* and some embedders do not preserve that behavior. */
|
|
1605
|
+
function windowsEnv(env, name) {
|
|
1606
|
+
const direct = env[name];
|
|
1607
|
+
if (direct !== void 0) return direct;
|
|
1608
|
+
const lowered = name.toLowerCase();
|
|
1609
|
+
for (const [key, value] of Object.entries(env)) if (key.toLowerCase() === lowered) return value;
|
|
1610
|
+
}
|
|
1574
1611
|
/**
|
|
1575
1612
|
* Candidate directories that may contain a `pwsh.exe` on Windows: PATH
|
|
1576
1613
|
* entries first, then the well-known machine/user install locations
|
|
@@ -1582,17 +1619,17 @@ var PtyManager = class {
|
|
|
1582
1619
|
*/
|
|
1583
1620
|
function windowsPwshCandidateDirs(env) {
|
|
1584
1621
|
const dirs = [];
|
|
1585
|
-
const pathEntries = env
|
|
1622
|
+
const pathEntries = windowsEnv(env, "PATH");
|
|
1586
1623
|
if (pathEntries !== void 0) for (const entry of pathEntries.split(";")) {
|
|
1587
1624
|
const trimmed = entry.trim();
|
|
1588
1625
|
if (trimmed !== "") dirs.push(trimmed);
|
|
1589
1626
|
}
|
|
1590
|
-
for (const programFiles of [env
|
|
1627
|
+
for (const programFiles of [windowsEnv(env, "ProgramW6432"), windowsEnv(env, "ProgramFiles")]) {
|
|
1591
1628
|
if (programFiles === void 0 || programFiles.trim() === "") continue;
|
|
1592
1629
|
dirs.push(join(programFiles, "PowerShell", "7"));
|
|
1593
1630
|
dirs.push(join(programFiles, "PowerShell", "7-preview"));
|
|
1594
1631
|
}
|
|
1595
|
-
const localAppData = env
|
|
1632
|
+
const localAppData = windowsEnv(env, "LOCALAPPDATA");
|
|
1596
1633
|
if (localAppData !== void 0 && localAppData.trim() !== "") {
|
|
1597
1634
|
dirs.push(join(localAppData, "Microsoft", "PowerShell", "7"));
|
|
1598
1635
|
dirs.push(join(localAppData, "Microsoft", "PowerShell", "7-preview"));
|
|
@@ -1602,6 +1639,63 @@ function windowsPwshCandidateDirs(env) {
|
|
|
1602
1639
|
return [...new Set(dirs)];
|
|
1603
1640
|
}
|
|
1604
1641
|
/**
|
|
1642
|
+
* Resolve the configured shell executable before handing it to node-pty.
|
|
1643
|
+
*
|
|
1644
|
+
* Windows' native backend does not consistently apply the shell's PATHEXT
|
|
1645
|
+
* lookup to a bare value (`pwsh` / `cmd` can fail with the opaque
|
|
1646
|
+
* `File not found:` error), so perform the lookup ourselves: an explicit
|
|
1647
|
+
* path is accepted as-is when it exists (or with a PATHEXT suffix when the
|
|
1648
|
+
* user omitted `.exe`), and a bare name is searched through PATH, System32,
|
|
1649
|
+
* and PowerShell's known install directories.
|
|
1650
|
+
*
|
|
1651
|
+
* POSIX node-pty uses `execvp`, so a bare name would already follow PATH —
|
|
1652
|
+
* but a wrong name made the pty die with a bare
|
|
1653
|
+
* `[process exited with code N]`, so probe like Windows anyway: a path with
|
|
1654
|
+
* a separator must exist; a bare name is searched along PATH (the colon
|
|
1655
|
+
* form is fixed by the platform). A miss is a clear, actionable
|
|
1656
|
+
* `shell-not-found` error instead of a cryptic exit code.
|
|
1657
|
+
*
|
|
1658
|
+
* @param shell - the configured shell (settings page or yaml `config.shell`).
|
|
1659
|
+
* @param options - platform/env/exists injection points for tests.
|
|
1660
|
+
* @returns the executable path passed to node-pty.
|
|
1661
|
+
* @throws {SidebarError} `shell-not-found` when no candidate exists.
|
|
1662
|
+
*/
|
|
1663
|
+
function resolveShellExecutable(shell, options = {}) {
|
|
1664
|
+
const configured = unquotePath(shell.trim());
|
|
1665
|
+
if (configured === "") return configured;
|
|
1666
|
+
const platform = options.platform ?? process.platform;
|
|
1667
|
+
const env = options.env ?? process.env;
|
|
1668
|
+
const exists = options.exists ?? existsSync;
|
|
1669
|
+
const notFound = () => new SidebarError("shell-not-found", `shell executable not found: "${configured}"`, 400, { shell: configured });
|
|
1670
|
+
if (platform === "win32") {
|
|
1671
|
+
const executableExts = (windowsEnv(env, "PATHEXT") ?? ".COM;.EXE").split(";").map((extension) => extension.trim()).filter((extension) => /^\.(?:com|exe)$/i.test(extension));
|
|
1672
|
+
if (executableExts.length === 0) executableExts.push(".EXE", ".COM");
|
|
1673
|
+
const names = win32.extname(configured) !== "" ? [configured] : executableExts.map((extension) => configured + extension.toLowerCase());
|
|
1674
|
+
const hasPath = win32.isAbsolute(configured) || /[\\/]/.test(configured);
|
|
1675
|
+
const candidates = [];
|
|
1676
|
+
if (hasPath) candidates.push(...names);
|
|
1677
|
+
else {
|
|
1678
|
+
const path = windowsEnv(env, "PATH");
|
|
1679
|
+
if (path !== void 0) for (const dir of path.split(";").map((entry) => entry.trim()).filter(Boolean)) for (const name of names) candidates.push(win32.join(dir, name));
|
|
1680
|
+
const systemRoot = windowsEnv(env, "SystemRoot");
|
|
1681
|
+
if (systemRoot !== void 0 && systemRoot.trim() !== "") for (const name of names) candidates.push(win32.join(systemRoot, "System32", name));
|
|
1682
|
+
if (/^pwsh(?:\.exe)?$/i.test(configured)) for (const dir of windowsPwshCandidateDirs(env)) candidates.push(win32.join(dir, "pwsh.exe"));
|
|
1683
|
+
}
|
|
1684
|
+
for (const candidate of [...new Set(candidates)]) if (exists(candidate)) return candidate;
|
|
1685
|
+
throw notFound();
|
|
1686
|
+
}
|
|
1687
|
+
if (configured.includes("/")) {
|
|
1688
|
+
if (!exists(configured)) throw notFound();
|
|
1689
|
+
return configured;
|
|
1690
|
+
}
|
|
1691
|
+
const path = env.PATH ?? "/usr/bin:/bin";
|
|
1692
|
+
for (const dir of path.split(":").map((entry) => entry.trim()).filter(Boolean)) {
|
|
1693
|
+
const candidate = join(dir, configured);
|
|
1694
|
+
if (exists(candidate)) return candidate;
|
|
1695
|
+
}
|
|
1696
|
+
throw notFound();
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1605
1699
|
* The interactive shell for this platform, resolved like a terminal
|
|
1606
1700
|
* emulator: an explicitly configured shell (the `shell` config field) wins,
|
|
1607
1701
|
* then `$SHELL` on POSIX (deployment override), then the account's login
|
|
@@ -1663,6 +1757,20 @@ function shellSpawnArgs(configured = []) {
|
|
|
1663
1757
|
if (configured.length > 0) return [...configured];
|
|
1664
1758
|
return process.platform === "win32" ? [] : ["-l"];
|
|
1665
1759
|
}
|
|
1760
|
+
/**
|
|
1761
|
+
* Strip ONE pair of surrounding quotes from a configured shell path. Users
|
|
1762
|
+
* paste Windows paths with spaces pre-quoted (`"C:\Program Files\…"`); the
|
|
1763
|
+
* quotes are shell-input syntax, not part of the path. Unpaired quotes and
|
|
1764
|
+
* shorter values stay verbatim.
|
|
1765
|
+
*/
|
|
1766
|
+
function unquotePath(value) {
|
|
1767
|
+
if (value.length >= 2) {
|
|
1768
|
+
const first = value[0];
|
|
1769
|
+
const last = value[value.length - 1];
|
|
1770
|
+
if (first === "\"" && last === "\"" || first === "'" && last === "'") return value.slice(1, -1);
|
|
1771
|
+
}
|
|
1772
|
+
return value;
|
|
1773
|
+
}
|
|
1666
1774
|
//#endregion
|
|
1667
1775
|
//#region src/agent-pty.ts
|
|
1668
1776
|
/**
|
|
@@ -1698,6 +1806,61 @@ function clampDims(cols, rows) {
|
|
|
1698
1806
|
rows: clamp(rows)
|
|
1699
1807
|
};
|
|
1700
1808
|
}
|
|
1809
|
+
/**
|
|
1810
|
+
* node-pty's Windows terminal queues resize calls that arrive before the
|
|
1811
|
+
* ConPTY control socket's first data flush (`_deferNoArgs` in
|
|
1812
|
+
* windowsTerminal.js). If the pty exits before the queue flushes, the
|
|
1813
|
+
* deferred resize throws inside the socket's 'data' handler — uncatchable
|
|
1814
|
+
* by any caller and fatal to the host process. POSIX terminals have no such
|
|
1815
|
+
* queue (resize is synchronous), so the gate is armed on Windows only:
|
|
1816
|
+
* {@link tryResizePty} parks dims requested before the first output and
|
|
1817
|
+
* replays them after the flush, when node-pty executes resizes
|
|
1818
|
+
* synchronously (and the throw for an exited pty is catchable).
|
|
1819
|
+
*/
|
|
1820
|
+
const ptyResizeGates = /* @__PURE__ */ new WeakMap();
|
|
1821
|
+
/**
|
|
1822
|
+
* Arm the Windows pre-ready resize gate for one freshly spawned pty.
|
|
1823
|
+
* No-op on POSIX and for injected ptys without `onData`.
|
|
1824
|
+
*/
|
|
1825
|
+
function armPtyResizeGate(pty) {
|
|
1826
|
+
if (process.platform !== "win32") return;
|
|
1827
|
+
if (typeof pty.onData !== "function" || ptyResizeGates.has(pty)) return;
|
|
1828
|
+
const state = { sawData: false };
|
|
1829
|
+
ptyResizeGates.set(pty, state);
|
|
1830
|
+
pty.onData(() => {
|
|
1831
|
+
if (state.sawData) return;
|
|
1832
|
+
state.sawData = true;
|
|
1833
|
+
const dims = state.pending;
|
|
1834
|
+
state.pending = void 0;
|
|
1835
|
+
if (dims === void 0) return;
|
|
1836
|
+
setImmediate(() => {
|
|
1837
|
+
tryResizePty(pty, dims.cols, dims.rows);
|
|
1838
|
+
});
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* Best-effort resize for WebSocket-driven terminal views. Layout animation
|
|
1843
|
+
* can briefly produce unusable dimensions, and node-pty can reject a resize
|
|
1844
|
+
* after the socket setup's outer try/catch has returned. Ignore that one
|
|
1845
|
+
* frame so the host stays alive and a later valid measurement can retry.
|
|
1846
|
+
* Returns whether node-pty accepted the resize (or parked it for replay on
|
|
1847
|
+
* the first output — the Windows pre-ready window).
|
|
1848
|
+
*/
|
|
1849
|
+
function tryResizePty(pty, cols, rows) {
|
|
1850
|
+
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return false;
|
|
1851
|
+
const dims = clampDims(cols, rows);
|
|
1852
|
+
const gate = ptyResizeGates.get(pty);
|
|
1853
|
+
if (gate !== void 0 && !gate.sawData) {
|
|
1854
|
+
gate.pending = dims;
|
|
1855
|
+
return true;
|
|
1856
|
+
}
|
|
1857
|
+
try {
|
|
1858
|
+
pty.resize(dims.cols, dims.rows);
|
|
1859
|
+
return true;
|
|
1860
|
+
} catch {
|
|
1861
|
+
return false;
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1701
1864
|
/** Map a POSIX signal number to its conventional name (best-effort). */
|
|
1702
1865
|
const SIGNAL_NAMES = {
|
|
1703
1866
|
1: "SIGHUP",
|
|
@@ -1720,20 +1883,54 @@ function signalNameOf(signal) {
|
|
|
1720
1883
|
if (signal === null || signal === void 0) return null;
|
|
1721
1884
|
return SIGNAL_NAMES[signal] ?? `signal ${signal}`;
|
|
1722
1885
|
}
|
|
1723
|
-
/**
|
|
1724
|
-
|
|
1886
|
+
/**
|
|
1887
|
+
* Compile a wait needle into a RegExp. The needle is treated as a JavaScript
|
|
1888
|
+
* regular expression; a pattern that fails to compile (e.g. an unbalanced
|
|
1889
|
+
* group typed as a literal) degrades to verbatim substring matching so
|
|
1890
|
+
* legacy literal needles keep working.
|
|
1891
|
+
*/
|
|
1892
|
+
function compileNeedle(needle) {
|
|
1893
|
+
try {
|
|
1894
|
+
return new RegExp(needle);
|
|
1895
|
+
} catch {
|
|
1896
|
+
return null;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* Locate the first occurrence of `needle` in `transcript`, returning its
|
|
1901
|
+
* line/column plus the actual matched text — for alternation patterns
|
|
1902
|
+
* (e.g. `(BUILD_OK|BUILD_FAIL)`) the match tells which alternative hit.
|
|
1903
|
+
* `re` is the precompiled form of `needle` (from {@link compileNeedle});
|
|
1904
|
+
* `null` means verbatim substring matching.
|
|
1905
|
+
*/
|
|
1906
|
+
function locateNeedle(transcript, needle, re) {
|
|
1725
1907
|
if (needle === "") return void 0;
|
|
1726
|
-
|
|
1727
|
-
if (
|
|
1908
|
+
let hit;
|
|
1909
|
+
if (re !== null) {
|
|
1910
|
+
re.lastIndex = 0;
|
|
1911
|
+
const m = re.exec(transcript);
|
|
1912
|
+
hit = m === null ? void 0 : {
|
|
1913
|
+
index: m.index,
|
|
1914
|
+
text: m[0]
|
|
1915
|
+
};
|
|
1916
|
+
} else {
|
|
1917
|
+
const idx = transcript.indexOf(needle);
|
|
1918
|
+
hit = idx === -1 ? void 0 : {
|
|
1919
|
+
index: idx,
|
|
1920
|
+
text: needle
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
if (hit === void 0) return void 0;
|
|
1728
1924
|
let line = 0;
|
|
1729
1925
|
let lineStart = 0;
|
|
1730
|
-
for (let i = 0; i <
|
|
1926
|
+
for (let i = 0; i < hit.index; i += 1) if (transcript.charCodeAt(i) === 10) {
|
|
1731
1927
|
line += 1;
|
|
1732
1928
|
lineStart = i + 1;
|
|
1733
1929
|
}
|
|
1734
1930
|
return {
|
|
1735
1931
|
line,
|
|
1736
|
-
column:
|
|
1932
|
+
column: hit.index - lineStart,
|
|
1933
|
+
match: hit.text
|
|
1737
1934
|
};
|
|
1738
1935
|
}
|
|
1739
1936
|
/** Snapshot projection of a handle (drops the pty reference and transcript). */
|
|
@@ -1748,6 +1945,11 @@ function snapshotOf(handle) {
|
|
|
1748
1945
|
out.exitCode = handle.exitCode ?? null;
|
|
1749
1946
|
out.exitSignal = signalNameOf(handle.exitSignal);
|
|
1750
1947
|
}
|
|
1948
|
+
const active = handle.waits.at(-1);
|
|
1949
|
+
if (active !== void 0) out.waiting = {
|
|
1950
|
+
needle: active.needle,
|
|
1951
|
+
since: active.since
|
|
1952
|
+
};
|
|
1751
1953
|
return out;
|
|
1752
1954
|
}
|
|
1753
1955
|
/**
|
|
@@ -1779,13 +1981,15 @@ var AgentPtyRegistry = class {
|
|
|
1779
1981
|
create(sessionId, title, command, cwd, cols = 80, rows = 24, shell, shellArgs) {
|
|
1780
1982
|
const uuid = randomUUID();
|
|
1781
1983
|
const dims = clampDims(cols, rows);
|
|
1782
|
-
const
|
|
1984
|
+
const executable = resolveShellExecutable(shell ?? this.shell);
|
|
1985
|
+
const pty = this.nodePty.spawn(executable, shellSpawnArgs(shellArgs ?? this.shellArgs), {
|
|
1783
1986
|
name: "xterm-256color",
|
|
1784
1987
|
cols: dims.cols,
|
|
1785
1988
|
rows: dims.rows,
|
|
1786
1989
|
cwd,
|
|
1787
1990
|
env: { ...process.env }
|
|
1788
1991
|
});
|
|
1992
|
+
armPtyResizeGate(pty);
|
|
1789
1993
|
const handle = {
|
|
1790
1994
|
uuid,
|
|
1791
1995
|
sessionId,
|
|
@@ -1794,7 +1998,8 @@ var AgentPtyRegistry = class {
|
|
|
1794
1998
|
cwd,
|
|
1795
1999
|
pty,
|
|
1796
2000
|
transcript: "",
|
|
1797
|
-
exited: false
|
|
2001
|
+
exited: false,
|
|
2002
|
+
waits: []
|
|
1798
2003
|
};
|
|
1799
2004
|
pty.onData((data) => {
|
|
1800
2005
|
handle.transcript += data;
|
|
@@ -1878,7 +2083,7 @@ var AgentPtyRegistry = class {
|
|
|
1878
2083
|
resize(uuid, cols, rows) {
|
|
1879
2084
|
const handle = this.expect(uuid);
|
|
1880
2085
|
const dims = clampDims(cols, rows);
|
|
1881
|
-
if (!handle.exited) handle.pty
|
|
2086
|
+
if (!handle.exited) tryResizePty(handle.pty, dims.cols, dims.rows);
|
|
1882
2087
|
return dims;
|
|
1883
2088
|
}
|
|
1884
2089
|
/**
|
|
@@ -1901,10 +2106,17 @@ var AgentPtyRegistry = class {
|
|
|
1901
2106
|
* make event-driven wakeups unreliable. A 50ms poll is fast enough for
|
|
1902
2107
|
* interactive use and simple enough to be obviously correct.
|
|
1903
2108
|
* @param uuid - terminal to watch.
|
|
1904
|
-
* @param needle -
|
|
2109
|
+
* @param needle - JavaScript regular expression to search for
|
|
2110
|
+
* (case-sensitive); a pattern that fails to compile falls back to
|
|
2111
|
+
* verbatim substring matching. May cover several outcomes at once
|
|
2112
|
+
* (e.g. `(BUILD_OK|BUILD_FAIL)` for build success vs failure) — the
|
|
2113
|
+
* returned `match` reports the text that actually matched, so callers
|
|
2114
|
+
* can tell which outcome hit.
|
|
1905
2115
|
* @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms.
|
|
1906
2116
|
* @param signal - caller-owned cancellation; aborts the wait re-throwing.
|
|
1907
|
-
*
|
|
2117
|
+
* A wait can also be skipped by the user from the sidebar banner
|
|
2118
|
+
* (`skipWait`), which resolves it with `{kind:'skipped'}`.
|
|
2119
|
+
* @returns one of `found` / `timeout` / `exited` / `skipped`.
|
|
1908
2120
|
*/
|
|
1909
2121
|
async waitFor(uuid, needle, timeoutMs = 1e4, signal) {
|
|
1910
2122
|
if (needle === "") throw new SidebarError("bad-request", "needle must be a non-empty string", 400);
|
|
@@ -1912,49 +2124,85 @@ var AgentPtyRegistry = class {
|
|
|
1912
2124
|
const timeout = Math.max(100, Math.floor(timeoutMs));
|
|
1913
2125
|
const start = Date.now();
|
|
1914
2126
|
const deadline = start + timeout;
|
|
2127
|
+
const re = compileNeedle(needle);
|
|
1915
2128
|
if (handle.exited) return {
|
|
1916
2129
|
kind: "exited",
|
|
1917
2130
|
needle,
|
|
1918
2131
|
exitCode: handle.exitCode ?? null,
|
|
1919
2132
|
exitSignal: signalNameOf(handle.exitSignal)
|
|
1920
2133
|
};
|
|
1921
|
-
const firstHit = locateNeedle(handle.transcript, needle);
|
|
2134
|
+
const firstHit = locateNeedle(handle.transcript, needle, re);
|
|
1922
2135
|
if (firstHit !== void 0) return {
|
|
1923
2136
|
kind: "found",
|
|
1924
2137
|
needle,
|
|
1925
2138
|
line: firstHit.line,
|
|
1926
2139
|
column: firstHit.column,
|
|
2140
|
+
match: firstHit.match,
|
|
1927
2141
|
elapsedMs: Date.now() - start
|
|
1928
2142
|
};
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
2143
|
+
const record = {
|
|
2144
|
+
needle,
|
|
2145
|
+
since: start,
|
|
2146
|
+
skipped: false
|
|
2147
|
+
};
|
|
2148
|
+
handle.waits.push(record);
|
|
2149
|
+
this.notify();
|
|
2150
|
+
try {
|
|
2151
|
+
while (true) {
|
|
2152
|
+
if (signal?.aborted) signal.throwIfAborted();
|
|
2153
|
+
if (handle.exited) return {
|
|
2154
|
+
kind: "exited",
|
|
2155
|
+
needle,
|
|
2156
|
+
exitCode: handle.exitCode ?? null,
|
|
2157
|
+
exitSignal: signalNameOf(handle.exitSignal)
|
|
2158
|
+
};
|
|
2159
|
+
if (record.skipped) return {
|
|
2160
|
+
kind: "skipped",
|
|
2161
|
+
needle
|
|
2162
|
+
};
|
|
2163
|
+
const hit = locateNeedle(handle.transcript, needle, re);
|
|
2164
|
+
if (hit !== void 0) return {
|
|
2165
|
+
kind: "found",
|
|
2166
|
+
needle,
|
|
2167
|
+
line: hit.line,
|
|
2168
|
+
column: hit.column,
|
|
2169
|
+
match: hit.match,
|
|
2170
|
+
elapsedMs: Date.now() - start
|
|
2171
|
+
};
|
|
2172
|
+
if (Date.now() >= deadline) return {
|
|
2173
|
+
kind: "timeout",
|
|
2174
|
+
needle,
|
|
2175
|
+
timeoutMs: timeout,
|
|
2176
|
+
totalLines: handle.transcript.split("\n").length
|
|
2177
|
+
};
|
|
2178
|
+
await new Promise((resolve) => {
|
|
2179
|
+
const t = setTimeout(resolve, 50);
|
|
2180
|
+
if (typeof t === "object" && "unref" in t) t.unref();
|
|
2181
|
+
});
|
|
2182
|
+
}
|
|
2183
|
+
} finally {
|
|
2184
|
+
const index = handle.waits.indexOf(record);
|
|
2185
|
+
if (index !== -1) handle.waits.splice(index, 1);
|
|
2186
|
+
this.notify();
|
|
1955
2187
|
}
|
|
1956
2188
|
}
|
|
1957
2189
|
/**
|
|
2190
|
+
* Mark every active wait on one terminal as skipped (the sidebar banner's
|
|
2191
|
+
* skip button). Each waiting poll loop observes its record's flag within
|
|
2192
|
+
* one 50ms tick and returns `{kind:'skipped'}`. Idempotent: 0 when nothing
|
|
2193
|
+
* is waiting (a stale banner racing a wait that already resolved).
|
|
2194
|
+
* @returns the number of waits that transitioned to skipped.
|
|
2195
|
+
*/
|
|
2196
|
+
skipWait(uuid) {
|
|
2197
|
+
const handle = this.expect(uuid);
|
|
2198
|
+
let count = 0;
|
|
2199
|
+
for (const record of handle.waits) if (!record.skipped) {
|
|
2200
|
+
record.skipped = true;
|
|
2201
|
+
count += 1;
|
|
2202
|
+
}
|
|
2203
|
+
return count;
|
|
2204
|
+
}
|
|
2205
|
+
/**
|
|
1958
2206
|
* Send a POSIX signal to a terminal's foreground process.
|
|
1959
2207
|
*
|
|
1960
2208
|
* Two delivery paths, by signal kind:
|
|
@@ -2327,7 +2575,7 @@ function registerTools(ctx, registry, resolveCwd, readShellOverrides) {
|
|
|
2327
2575
|
}));
|
|
2328
2576
|
register(defineTool({
|
|
2329
2577
|
name: "terminal_wait_for",
|
|
2330
|
-
description: "Block until a
|
|
2578
|
+
description: "Block until a pattern appears in a terminal's retained transcript, or until the timeout elapses, or until the terminal exits — whichever happens first. Use this to synchronize on command completion cues ( e.g. a shell prompt, \"done\", \"Listening on\", \"Build successful\" ) without busy-polling terminal_read. The needle is a JavaScript regular expression ( a pattern that fails to compile falls back to verbatim substring matching ). One needle may cover MULTIPLE outcomes — e.g. wait on `(BUILD_OK|BUILD_FAIL)` or `Build (succeeded|failed)` returns as soon as EITHER marker appears, and the found result's `match` field tells which alternative hit ( build success vs failure ). The wait scans the FULL retained transcript (up to ~1 MiB) on every poll, so a needle that scrolled past the most recent chunk is still a match. Returns `found` with the line/column and the matched text, `timeout` if the needle did not appear in time, or `exited` if the terminal process died before the needle appeared. Default timeout is 10 seconds; raise it for long-running commands ( dev servers, test suites ). The wait is cooperative: a tool-call cancel ( or agent turn end ) aborts it immediately. The user can skip the wait from the sidebar ( a banner on the terminal's tab shows the needle and a skip button ) — the tool then returns `skipped`.",
|
|
2331
2579
|
parameters: {
|
|
2332
2580
|
uuid: {
|
|
2333
2581
|
type: "string",
|
|
@@ -2337,7 +2585,7 @@ function registerTools(ctx, registry, resolveCwd, readShellOverrides) {
|
|
|
2337
2585
|
needle: {
|
|
2338
2586
|
type: "string",
|
|
2339
2587
|
required: true,
|
|
2340
|
-
description: "
|
|
2588
|
+
description: "JavaScript regular expression to wait for (case-sensitive); a pattern that fails to compile falls back to verbatim substring matching. May cover several outcomes in one wait ( e.g. `(BUILD_OK|BUILD_FAIL)` for build success/failure ) — check `match` in the found result to see which one hit. Must be non-empty."
|
|
2341
2589
|
},
|
|
2342
2590
|
timeout_ms: {
|
|
2343
2591
|
type: "number",
|
|
@@ -2369,6 +2617,11 @@ function registerTools(ctx, registry, resolveCwd, readShellOverrides) {
|
|
|
2369
2617
|
required: true,
|
|
2370
2618
|
description: "0-based column index within that line where the match starts."
|
|
2371
2619
|
},
|
|
2620
|
+
match: {
|
|
2621
|
+
type: "string",
|
|
2622
|
+
required: true,
|
|
2623
|
+
description: "The text that actually matched — for multi-outcome patterns ( e.g. `(BUILD_OK|BUILD_FAIL)` ) this tells which alternative matched."
|
|
2624
|
+
},
|
|
2372
2625
|
elapsedMs: {
|
|
2373
2626
|
type: "integer",
|
|
2374
2627
|
required: true,
|
|
@@ -2423,18 +2676,40 @@ function registerTools(ctx, registry, resolveCwd, readShellOverrides) {
|
|
|
2423
2676
|
description: "Exit signal name, if killed by a signal."
|
|
2424
2677
|
}
|
|
2425
2678
|
}
|
|
2679
|
+
},
|
|
2680
|
+
{
|
|
2681
|
+
type: "object",
|
|
2682
|
+
additionalProperties: false,
|
|
2683
|
+
properties: {
|
|
2684
|
+
kind: {
|
|
2685
|
+
type: "string",
|
|
2686
|
+
required: true,
|
|
2687
|
+
const: "skipped"
|
|
2688
|
+
},
|
|
2689
|
+
needle: {
|
|
2690
|
+
type: "string",
|
|
2691
|
+
required: true
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2426
2694
|
}
|
|
2427
2695
|
] },
|
|
2428
2696
|
render: (_args, value) => {
|
|
2429
2697
|
const v = value;
|
|
2430
|
-
if (v.kind === "found")
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2698
|
+
if (v.kind === "found") {
|
|
2699
|
+
const matched = v.match !== void 0 && v.match !== "" ? `, matched "${v.match}"` : "";
|
|
2700
|
+
return [{
|
|
2701
|
+
type: "text",
|
|
2702
|
+
text: `Found "${v.needle}" at line ${v.line}, column ${v.column}${matched} (after ${v.elapsedMs}ms).`
|
|
2703
|
+
}];
|
|
2704
|
+
}
|
|
2434
2705
|
if (v.kind === "timeout") return [{
|
|
2435
2706
|
type: "text",
|
|
2436
2707
|
text: `Timed out after ${v.timeoutMs}ms waiting for "${v.needle}". Call terminal_read to inspect the transcript.`
|
|
2437
2708
|
}];
|
|
2709
|
+
if (v.kind === "skipped") return [{
|
|
2710
|
+
type: "text",
|
|
2711
|
+
text: `Skipped by user while waiting for "${v.needle}" — the wait ended early. Call terminal_read to inspect the transcript and decide how to proceed.`
|
|
2712
|
+
}];
|
|
2438
2713
|
const exitInfo = v.exitCode !== void 0 && v.exitCode !== null ? ` (exit code ${v.exitCode})` : "";
|
|
2439
2714
|
return [{
|
|
2440
2715
|
type: "text",
|
|
@@ -3505,12 +3780,13 @@ function buildSidechatApi(ctx) {
|
|
|
3505
3780
|
meta: {
|
|
3506
3781
|
...parentSession.header.cwd === void 0 ? {} : { cwd: parentSession.header.cwd },
|
|
3507
3782
|
parentSession: parentSession.id,
|
|
3508
|
-
isSeeded:
|
|
3783
|
+
isSeeded: true,
|
|
3509
3784
|
origin: "subagent",
|
|
3510
3785
|
delegationDepth: (parentSession.header.delegationDepth ?? 0) + 1,
|
|
3511
3786
|
...agentPreset === void 0 ? {} : { agentPreset }
|
|
3512
3787
|
},
|
|
3513
3788
|
seed,
|
|
3789
|
+
inheritedEventCount: SessionLogOffset(seed.length),
|
|
3514
3790
|
agentOptions: { ...parent.options },
|
|
3515
3791
|
setup,
|
|
3516
3792
|
signal: AbortSignal.timeout(CREATE_TIMEOUT_MS)
|
|
@@ -4340,6 +4616,33 @@ async function attachAgentList(registry, ws, req) {
|
|
|
4340
4616
|
}
|
|
4341
4617
|
}
|
|
4342
4618
|
/**
|
|
4619
|
+
* The WS close reason for a failed terminal attach. A missing configured
|
|
4620
|
+
* shell gets a SHORT machine-readable marker (`shell-not-found:<name>`,
|
|
4621
|
+
* capped by BYTES — a WS close reason allows at most 123 bytes, which `ws`
|
|
4622
|
+
* validates with `Buffer.byteLength`) that the client maps to a localized,
|
|
4623
|
+
* actionable banner; every other failure keeps the raw message (the
|
|
4624
|
+
* model-side tool errors read it verbatim).
|
|
4625
|
+
*/
|
|
4626
|
+
function wsCloseReasonOf(error) {
|
|
4627
|
+
if (error instanceof SidebarError && error.code === "shell-not-found") return `shell-not-found:${truncateUtf8Bytes(shellDisplayName(String(error.meta?.shell ?? "")), 100)}`;
|
|
4628
|
+
return error instanceof Error ? error.message : String(error);
|
|
4629
|
+
}
|
|
4630
|
+
/**
|
|
4631
|
+
* Truncate to at most `maxBytes` UTF-8 bytes without splitting a code point.
|
|
4632
|
+
* A character-count `slice` does not bound the WS close reason: `ws` measures
|
|
4633
|
+
* `Buffer.byteLength` against its 123-byte cap, and the resulting throw would
|
|
4634
|
+
* replace the very error the reason describes.
|
|
4635
|
+
*/
|
|
4636
|
+
function truncateUtf8Bytes(value, maxBytes) {
|
|
4637
|
+
if (Buffer.byteLength(value) <= maxBytes) return value;
|
|
4638
|
+
let truncated = "";
|
|
4639
|
+
for (const character of value) {
|
|
4640
|
+
if (Buffer.byteLength(truncated + character) > maxBytes) break;
|
|
4641
|
+
truncated += character;
|
|
4642
|
+
}
|
|
4643
|
+
return truncated;
|
|
4644
|
+
}
|
|
4645
|
+
/**
|
|
4343
4646
|
* Wire one terminal socket to its pty: replay transcript, pump both ways.
|
|
4344
4647
|
* Two attach modes share the wire protocol:
|
|
4345
4648
|
* - `?uuid=...` attaches to an agent-owned terminal (created by the
|
|
@@ -4385,6 +4688,7 @@ async function attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolv
|
|
|
4385
4688
|
const cwd = await sessionCwdOf(ctx, sessionId, url.searchParams.get("cwd") ?? void 0);
|
|
4386
4689
|
const overrides = shellOverridesOf(getSettings);
|
|
4387
4690
|
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24, overrides.shell, overrides.shellArgs);
|
|
4691
|
+
armPtyResizeGate(handle.pty);
|
|
4388
4692
|
if (handle.transcript !== "") ws.send(handle.transcript);
|
|
4389
4693
|
const onData = (data) => {
|
|
4390
4694
|
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4194304) ws.send(data);
|
|
@@ -4410,10 +4714,8 @@ async function attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolv
|
|
|
4410
4714
|
return;
|
|
4411
4715
|
}
|
|
4412
4716
|
if (handle.exited) return;
|
|
4413
|
-
if (control !== null && control.type === "resize" && typeof control.cols === "number" && typeof control.rows === "number")
|
|
4414
|
-
|
|
4415
|
-
handle.pty.resize(dims.cols, dims.rows);
|
|
4416
|
-
} else handle.pty.write(text);
|
|
4717
|
+
if (control !== null && control.type === "resize" && typeof control.cols === "number" && typeof control.rows === "number") tryResizePty(handle.pty, control.cols, control.rows);
|
|
4718
|
+
else handle.pty.write(text);
|
|
4417
4719
|
});
|
|
4418
4720
|
ws.on("close", () => {
|
|
4419
4721
|
dataSub.dispose();
|
|
@@ -4421,7 +4723,7 @@ async function attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolv
|
|
|
4421
4723
|
if (!ptyManager.isParked(handle.key)) ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs);
|
|
4422
4724
|
});
|
|
4423
4725
|
} catch (error) {
|
|
4424
|
-
ws.close(1011,
|
|
4726
|
+
ws.close(1011, wsCloseReasonOf(error));
|
|
4425
4727
|
}
|
|
4426
4728
|
}
|
|
4427
4729
|
/**
|
|
@@ -4453,10 +4755,8 @@ function pumpAgentTerminal(registry, handle, ws) {
|
|
|
4453
4755
|
registry.close(handle.uuid);
|
|
4454
4756
|
return;
|
|
4455
4757
|
}
|
|
4456
|
-
if (control !== null && control.type === "resize" && typeof control.cols === "number" && typeof control.rows === "number")
|
|
4457
|
-
|
|
4458
|
-
handle.pty.resize(dims.cols, dims.rows);
|
|
4459
|
-
} else if (control === null) handle.pty.write(text);
|
|
4758
|
+
if (control !== null && control.type === "resize" && typeof control.cols === "number" && typeof control.rows === "number") tryResizePty(handle.pty, control.cols, control.rows);
|
|
4759
|
+
else if (control === null) handle.pty.write(text);
|
|
4460
4760
|
});
|
|
4461
4761
|
ws.on("close", () => {
|
|
4462
4762
|
dataSub.dispose();
|
|
@@ -4464,4 +4764,4 @@ function pumpAgentTerminal(registry, handle, ws) {
|
|
|
4464
4764
|
});
|
|
4465
4765
|
}
|
|
4466
4766
|
//#endregion
|
|
4467
|
-
export { Config, apply, inject, mediaTypeForPath, name };
|
|
4767
|
+
export { Config, apply, inject, mediaTypeForPath, name, wsCloseReasonOf };
|