dsh-coding-sidebar 1.0.7 → 1.0.9
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 +485 -228
- package/lib/client-registry.js +1238 -7583
- package/lib/client-terminal.js +345 -189
- package/lib/client.js +1213 -7558
- package/lib/index.js +518 -69
- package/lib/types/agent-pty.d.ts +62 -4
- package/lib/types/bundle-route.d.ts +1 -1
- package/lib/types/client/DiffView.d.ts +33 -1
- package/lib/types/client/EditorHost.d.ts +4 -1
- package/lib/types/client/FileTree.d.ts +6 -2
- package/lib/types/client/TerminalWaitBanner.d.ts +6 -0
- package/lib/types/client/TreePanel.d.ts +4 -1
- package/lib/types/client/api.d.ts +26 -0
- 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 +13 -20
- package/lib/types/client/selection-popup.d.ts +58 -0
- package/lib/types/client/service.d.ts +1 -1
- package/lib/types/client/state.d.ts +15 -0
- package/lib/types/client/terminal-font.d.ts +25 -2
- package/lib/types/context-types.d.ts +3 -1
- package/lib/types/fs-operations.d.ts +42 -0
- package/lib/types/git.d.ts +15 -0
- 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/DiffTab.tsx +10 -1
- package/src/client/DiffView.tsx +174 -19
- package/src/client/EditorHost.tsx +9 -2
- package/src/client/FileTree.tsx +151 -8
- package/src/client/Sidebar.tsx +95 -25
- package/src/client/TerminalView.tsx +41 -0
- package/src/client/TerminalWaitBanner.tsx +32 -0
- package/src/client/TextEditor.tsx +27 -45
- package/src/client/TreePanel.tsx +22 -2
- package/src/client/api.ts +20 -0
- 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 +17 -4
- package/src/client/locales-de.ts +17 -4
- package/src/client/locales-fr.ts +17 -4
- package/src/client/locales-hi.ts +17 -4
- package/src/client/locales-id.ts +17 -4
- package/src/client/locales-it.ts +17 -4
- package/src/client/locales-ja.ts +17 -4
- package/src/client/locales-ko.ts +17 -4
- package/src/client/locales-nl.ts +17 -4
- package/src/client/locales-pl.ts +17 -4
- package/src/client/locales-pt.ts +17 -4
- package/src/client/locales-ru.ts +17 -4
- package/src/client/locales-sv.ts +17 -4
- package/src/client/locales-th.ts +17 -4
- package/src/client/locales-tr.ts +17 -4
- package/src/client/locales-vi.ts +17 -4
- package/src/client/locales-zh-HK.ts +17 -4
- package/src/client/locales-zh-MO.ts +17 -4
- package/src/client/locales-zh-TW.ts +17 -4
- package/src/client/locales.ts +41 -51
- package/src/client/selection-popup.ts +155 -0
- package/src/client/service.ts +1 -1
- package/src/client/sidebar.module.css +68 -0
- package/src/client/state.ts +56 -10
- package/src/client/terminal-font.ts +34 -3
- package/src/context-types.ts +3 -2
- package/src/fs-operations.ts +126 -4
- package/src/git.ts +56 -3
- package/src/index.ts +82 -7
- 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
|
-
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";
|
|
2
|
+
import { access, lstat, mkdir, open, opendir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
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). */
|
|
@@ -374,7 +377,8 @@ async function ensureWorkspaceWritePath(cwd, target) {
|
|
|
374
377
|
//#endregion
|
|
375
378
|
//#region src/fs-operations.ts
|
|
376
379
|
/**
|
|
377
|
-
* Workspace-safe file mutations for the sidebar
|
|
380
|
+
* Workspace-safe file mutations for the sidebar: the upload route plus the
|
|
381
|
+
* file tree's rename and delete.
|
|
378
382
|
*
|
|
379
383
|
* Every write is confined to the real session workspace: the upload
|
|
380
384
|
* directory is resolved absolute and its target is checked through existing
|
|
@@ -384,6 +388,13 @@ async function ensureWorkspaceWritePath(cwd, target) {
|
|
|
384
388
|
* to a uniquely named temp sibling
|
|
385
389
|
* and are renamed into place, so a failed, aborted, or oversized upload never
|
|
386
390
|
* leaves a partial file at the target path.
|
|
391
|
+
*
|
|
392
|
+
* The tree's rename/delete (below) are link-aware: existence and containment
|
|
393
|
+
* are verified against the fully resolved target (a symlink pointing outside
|
|
394
|
+
* the workspace is refused), but the operation itself addresses the lexical
|
|
395
|
+
* row path — renaming or deleting a symlink row renames/unlinks the LINK,
|
|
396
|
+
* never its target, matching what the tree row visually names (VS Code
|
|
397
|
+
* semantics). Containment is always enforced (no fence toggle on this fork).
|
|
387
398
|
*/
|
|
388
399
|
/**
|
|
389
400
|
* Stream `chunks` into `dir/relativePath` atomically: a uniquely named temp
|
|
@@ -440,6 +451,86 @@ async function writeWorkspaceUpload(input) {
|
|
|
440
451
|
throw error;
|
|
441
452
|
}
|
|
442
453
|
}
|
|
454
|
+
/** Resolve a tree-row path against the session workspace (absolute rows pass through). */
|
|
455
|
+
function resolveRowPath(cwd, target) {
|
|
456
|
+
return isAbsolute(target) ? target : join(cwd, target);
|
|
457
|
+
}
|
|
458
|
+
/** Resolve one existing entry for a link-aware mutation: the lexical row path
|
|
459
|
+
* plus its fully resolved real target (containment-checked). Resolution
|
|
460
|
+
* failures become fs-errors, mirroring path-security's semantics. */
|
|
461
|
+
async function resolveEntry(cwd, target) {
|
|
462
|
+
const absolute = requireAbsolute(resolveRowPath(cwd, target));
|
|
463
|
+
let real;
|
|
464
|
+
let realCwd;
|
|
465
|
+
try {
|
|
466
|
+
[realCwd, real] = await Promise.all([realpath(cwd), realpath(absolute)]);
|
|
467
|
+
} catch (error) {
|
|
468
|
+
throw new SidebarError("fs-error", `cannot resolve "${target}": ${error instanceof Error ? error.message : String(error)}`, 400);
|
|
469
|
+
}
|
|
470
|
+
if (!isWithin(realCwd, real)) throw new SidebarError("forbidden", `path "${target}" is outside workspace`, 403);
|
|
471
|
+
return {
|
|
472
|
+
absolute,
|
|
473
|
+
real,
|
|
474
|
+
realCwd
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
/** Whether a path exists (ENOENT → false; other failures propagate). */
|
|
478
|
+
async function pathExists(target) {
|
|
479
|
+
try {
|
|
480
|
+
await access(target);
|
|
481
|
+
return true;
|
|
482
|
+
} catch (error) {
|
|
483
|
+
if (error.code === "ENOENT") return false;
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Rename one tree row within its directory: `path` → `<parent>/<name>`.
|
|
489
|
+
* The new name must be a single path segment (this is rename, not move);
|
|
490
|
+
* an existing destination is refused (POSIX rename would clobber it
|
|
491
|
+
* silently); the workspace root itself is never renamable; a symlink row
|
|
492
|
+
* renames the link, not its target. A no-op rename (same name) succeeds
|
|
493
|
+
* without touching the filesystem.
|
|
494
|
+
*
|
|
495
|
+
* @throws SidebarError with a wire code for shape, containment, existence
|
|
496
|
+
* and root failures.
|
|
497
|
+
*/
|
|
498
|
+
async function renameWorkspaceEntry(input) {
|
|
499
|
+
const { cwd, path, name } = input;
|
|
500
|
+
if (name === "" || name === "." || name === ".." || name.includes("/") || name.includes("\\")) throw new SidebarError("bad-request", "name must be a single path segment", 400);
|
|
501
|
+
const { absolute, real, realCwd } = await resolveEntry(cwd, path);
|
|
502
|
+
if (real === realCwd) throw new SidebarError("fs-error", "cannot rename the workspace root", 400);
|
|
503
|
+
if (basename(absolute) === name) return { path: absolute };
|
|
504
|
+
const safeDestination = await ensureWorkspaceWritePath(cwd, join(dirname(absolute), name));
|
|
505
|
+
if (await pathExists(safeDestination)) throw new SidebarError("fs-error", `"${name}" already exists`, 409);
|
|
506
|
+
try {
|
|
507
|
+
await rename(absolute, safeDestination);
|
|
508
|
+
} catch (error) {
|
|
509
|
+
throw new SidebarError("fs-error", `cannot rename "${path}" to "${name}": ${error instanceof Error ? error.message : String(error)}`, 400);
|
|
510
|
+
}
|
|
511
|
+
return { path: safeDestination };
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Delete one tree row permanently (there is no trash on the host): files are
|
|
515
|
+
* unlinked, directories removed recursively, a symlink row unlinks the LINK
|
|
516
|
+
* only (lstat decides, so a link to a directory does not recurse into its
|
|
517
|
+
* target). The workspace root itself is never removable.
|
|
518
|
+
*
|
|
519
|
+
* @throws SidebarError with a wire code for containment, existence and
|
|
520
|
+
* root failures.
|
|
521
|
+
*/
|
|
522
|
+
async function removeWorkspaceEntry(input) {
|
|
523
|
+
const { cwd, path } = input;
|
|
524
|
+
const { absolute, real, realCwd } = await resolveEntry(cwd, path);
|
|
525
|
+
if (real === realCwd) throw new SidebarError("fs-error", "cannot remove the workspace root", 400);
|
|
526
|
+
try {
|
|
527
|
+
if ((await lstat(absolute)).isDirectory()) await rm(absolute, { recursive: true });
|
|
528
|
+
else await unlink(absolute);
|
|
529
|
+
} catch (error) {
|
|
530
|
+
throw new SidebarError("fs-error", `cannot remove "${path}": ${error instanceof Error ? error.message : String(error)}`, 400);
|
|
531
|
+
}
|
|
532
|
+
return { path: absolute };
|
|
533
|
+
}
|
|
443
534
|
//#endregion
|
|
444
535
|
//#region src/fs-search.ts
|
|
445
536
|
/**
|
|
@@ -632,7 +723,11 @@ function isTrustedApiRequest(request, trustedHosts) {
|
|
|
632
723
|
* only allowlisted chunk names are servable (no path traversal).
|
|
633
724
|
*/
|
|
634
725
|
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
|
|
635
|
-
const CHUNK_NAMES = [
|
|
726
|
+
const CHUNK_NAMES = [
|
|
727
|
+
"terminal",
|
|
728
|
+
"editor",
|
|
729
|
+
"locale"
|
|
730
|
+
];
|
|
636
731
|
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
|
|
637
732
|
const LIB_DIR = dirname(fileURLToPath(import.meta.url));
|
|
638
733
|
/** sha1 content hash shortened to 12 hex chars (same shape as the client-modules rev). */
|
|
@@ -747,7 +842,7 @@ function revealCommand(path, platform = process.platform) {
|
|
|
747
842
|
};
|
|
748
843
|
case "win32": return {
|
|
749
844
|
command: "explorer.exe",
|
|
750
|
-
args: [
|
|
845
|
+
args: [`/select,${path}`]
|
|
751
846
|
};
|
|
752
847
|
default: return {
|
|
753
848
|
command: "xdg-open",
|
|
@@ -1060,16 +1155,37 @@ function pathIdentity(path) {
|
|
|
1060
1155
|
const absolute = resolve(path).replace(/[\\/]+$/, "");
|
|
1061
1156
|
return process.platform === "win32" ? absolute.toLowerCase() : absolute;
|
|
1062
1157
|
}
|
|
1158
|
+
/** Whether the current Git binary supports NUL-framed `worktree list` output.
|
|
1159
|
+
* Git < 2.36 rejects `-z`; cache the capability after the first attempt so
|
|
1160
|
+
* the SCM panel's polling does not repeatedly spawn a command known to fail. */
|
|
1161
|
+
let worktreeListSupportsZ;
|
|
1063
1162
|
/** Raw usable checkout records, shared by inventory and target validation.
|
|
1064
1163
|
* Prunable records point at missing paths and are deliberately excluded from
|
|
1065
1164
|
* both the selector and the command-target allowlist. */
|
|
1066
1165
|
async function listedWorktrees(cwd) {
|
|
1067
|
-
|
|
1166
|
+
let raw;
|
|
1167
|
+
if (worktreeListSupportsZ === false) raw = await runGit(cwd, [
|
|
1068
1168
|
"worktree",
|
|
1069
1169
|
"list",
|
|
1070
|
-
"--porcelain"
|
|
1071
|
-
|
|
1072
|
-
|
|
1170
|
+
"--porcelain"
|
|
1171
|
+
]);
|
|
1172
|
+
else try {
|
|
1173
|
+
raw = await runGit(cwd, [
|
|
1174
|
+
"worktree",
|
|
1175
|
+
"list",
|
|
1176
|
+
"--porcelain",
|
|
1177
|
+
"-z"
|
|
1178
|
+
]);
|
|
1179
|
+
worktreeListSupportsZ = true;
|
|
1180
|
+
} catch {
|
|
1181
|
+
worktreeListSupportsZ = false;
|
|
1182
|
+
raw = await runGit(cwd, [
|
|
1183
|
+
"worktree",
|
|
1184
|
+
"list",
|
|
1185
|
+
"--porcelain"
|
|
1186
|
+
]);
|
|
1187
|
+
}
|
|
1188
|
+
return parseWorktreeList(raw).filter((entry) => !entry.prunable);
|
|
1073
1189
|
}
|
|
1074
1190
|
/** All linked checkouts of the repository containing `cwd`, enriched with a
|
|
1075
1191
|
* live change count. The current checkout is first so a single-worktree repo
|
|
@@ -1173,6 +1289,36 @@ async function show(cwd, rev, path, selected) {
|
|
|
1173
1289
|
return null;
|
|
1174
1290
|
}
|
|
1175
1291
|
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Both sides' full file contents for a diff-fold expansion. `path` is
|
|
1294
|
+
* repo-relative. The sides resolve per diff kind: a commit reads
|
|
1295
|
+
* `<hash>^` vs `<hash>`; a staged change reads HEAD vs the index (`:`);
|
|
1296
|
+
* an unstaged change reads HEAD vs the working tree file on disk (a side
|
|
1297
|
+
* that does not exist — untracked, deleted, binary-refused — comes back
|
|
1298
|
+
* null and the client degrades the fold to a static marker).
|
|
1299
|
+
*/
|
|
1300
|
+
async function foldContents(cwd, path, opts = {}, selected) {
|
|
1301
|
+
const root = await repoRoot(cwd, selected);
|
|
1302
|
+
if (opts.hash !== void 0) {
|
|
1303
|
+
const [old, neu] = await Promise.all([show(root, `${opts.hash}^`, path, selected), show(root, opts.hash, path, selected)]);
|
|
1304
|
+
return {
|
|
1305
|
+
old,
|
|
1306
|
+
new: neu
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
if (opts.staged === true) {
|
|
1310
|
+
const [old, neu] = await Promise.all([show(root, "HEAD", path, selected), show(root, ":", path, selected)]);
|
|
1311
|
+
return {
|
|
1312
|
+
old,
|
|
1313
|
+
new: neu
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
const [old, neu] = await Promise.all([show(root, "HEAD", path, selected), readFile(isAbsolute(path) ? path : join(root, path), "utf8").catch(() => null)]);
|
|
1317
|
+
return {
|
|
1318
|
+
old,
|
|
1319
|
+
new: neu
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1176
1322
|
/** Full patch text of one commit (`git show` with the commit header suppressed).
|
|
1177
1323
|
* Merge commits show their diff against the first parent (`-m --first-parent`
|
|
1178
1324
|
* is a no-op for regular commits), so a history click always has content. */
|
|
@@ -1484,7 +1630,7 @@ var PtyManager = class {
|
|
|
1484
1630
|
sessionId,
|
|
1485
1631
|
tabId,
|
|
1486
1632
|
cwd,
|
|
1487
|
-
pty: this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
|
|
1633
|
+
pty: this.nodePty.spawn(resolveShellExecutable(shell ?? this.shell), shellSpawnArgs(shellArgs ?? this.shellArgs), {
|
|
1488
1634
|
name: "xterm-256color",
|
|
1489
1635
|
cols: Math.max(2, Math.floor(cols)),
|
|
1490
1636
|
rows: Math.max(2, Math.floor(rows)),
|
|
@@ -1571,6 +1717,15 @@ var PtyManager = class {
|
|
|
1571
1717
|
for (const key of [...this.sessions.keys()]) this.close(key);
|
|
1572
1718
|
}
|
|
1573
1719
|
};
|
|
1720
|
+
/** Read one Windows environment value case-insensitively. Real
|
|
1721
|
+
* `process.env` has case-insensitive lookup on Windows, but injected objects
|
|
1722
|
+
* and some embedders do not preserve that behavior. */
|
|
1723
|
+
function windowsEnv(env, name) {
|
|
1724
|
+
const direct = env[name];
|
|
1725
|
+
if (direct !== void 0) return direct;
|
|
1726
|
+
const lowered = name.toLowerCase();
|
|
1727
|
+
for (const [key, value] of Object.entries(env)) if (key.toLowerCase() === lowered) return value;
|
|
1728
|
+
}
|
|
1574
1729
|
/**
|
|
1575
1730
|
* Candidate directories that may contain a `pwsh.exe` on Windows: PATH
|
|
1576
1731
|
* entries first, then the well-known machine/user install locations
|
|
@@ -1582,17 +1737,17 @@ var PtyManager = class {
|
|
|
1582
1737
|
*/
|
|
1583
1738
|
function windowsPwshCandidateDirs(env) {
|
|
1584
1739
|
const dirs = [];
|
|
1585
|
-
const pathEntries = env
|
|
1740
|
+
const pathEntries = windowsEnv(env, "PATH");
|
|
1586
1741
|
if (pathEntries !== void 0) for (const entry of pathEntries.split(";")) {
|
|
1587
1742
|
const trimmed = entry.trim();
|
|
1588
1743
|
if (trimmed !== "") dirs.push(trimmed);
|
|
1589
1744
|
}
|
|
1590
|
-
for (const programFiles of [env
|
|
1745
|
+
for (const programFiles of [windowsEnv(env, "ProgramW6432"), windowsEnv(env, "ProgramFiles")]) {
|
|
1591
1746
|
if (programFiles === void 0 || programFiles.trim() === "") continue;
|
|
1592
1747
|
dirs.push(join(programFiles, "PowerShell", "7"));
|
|
1593
1748
|
dirs.push(join(programFiles, "PowerShell", "7-preview"));
|
|
1594
1749
|
}
|
|
1595
|
-
const localAppData = env
|
|
1750
|
+
const localAppData = windowsEnv(env, "LOCALAPPDATA");
|
|
1596
1751
|
if (localAppData !== void 0 && localAppData.trim() !== "") {
|
|
1597
1752
|
dirs.push(join(localAppData, "Microsoft", "PowerShell", "7"));
|
|
1598
1753
|
dirs.push(join(localAppData, "Microsoft", "PowerShell", "7-preview"));
|
|
@@ -1602,6 +1757,63 @@ function windowsPwshCandidateDirs(env) {
|
|
|
1602
1757
|
return [...new Set(dirs)];
|
|
1603
1758
|
}
|
|
1604
1759
|
/**
|
|
1760
|
+
* Resolve the configured shell executable before handing it to node-pty.
|
|
1761
|
+
*
|
|
1762
|
+
* Windows' native backend does not consistently apply the shell's PATHEXT
|
|
1763
|
+
* lookup to a bare value (`pwsh` / `cmd` can fail with the opaque
|
|
1764
|
+
* `File not found:` error), so perform the lookup ourselves: an explicit
|
|
1765
|
+
* path is accepted as-is when it exists (or with a PATHEXT suffix when the
|
|
1766
|
+
* user omitted `.exe`), and a bare name is searched through PATH, System32,
|
|
1767
|
+
* and PowerShell's known install directories.
|
|
1768
|
+
*
|
|
1769
|
+
* POSIX node-pty uses `execvp`, so a bare name would already follow PATH —
|
|
1770
|
+
* but a wrong name made the pty die with a bare
|
|
1771
|
+
* `[process exited with code N]`, so probe like Windows anyway: a path with
|
|
1772
|
+
* a separator must exist; a bare name is searched along PATH (the colon
|
|
1773
|
+
* form is fixed by the platform). A miss is a clear, actionable
|
|
1774
|
+
* `shell-not-found` error instead of a cryptic exit code.
|
|
1775
|
+
*
|
|
1776
|
+
* @param shell - the configured shell (settings page or yaml `config.shell`).
|
|
1777
|
+
* @param options - platform/env/exists injection points for tests.
|
|
1778
|
+
* @returns the executable path passed to node-pty.
|
|
1779
|
+
* @throws {SidebarError} `shell-not-found` when no candidate exists.
|
|
1780
|
+
*/
|
|
1781
|
+
function resolveShellExecutable(shell, options = {}) {
|
|
1782
|
+
const configured = unquotePath(shell.trim());
|
|
1783
|
+
if (configured === "") return configured;
|
|
1784
|
+
const platform = options.platform ?? process.platform;
|
|
1785
|
+
const env = options.env ?? process.env;
|
|
1786
|
+
const exists = options.exists ?? existsSync;
|
|
1787
|
+
const notFound = () => new SidebarError("shell-not-found", `shell executable not found: "${configured}"`, 400, { shell: configured });
|
|
1788
|
+
if (platform === "win32") {
|
|
1789
|
+
const executableExts = (windowsEnv(env, "PATHEXT") ?? ".COM;.EXE").split(";").map((extension) => extension.trim()).filter((extension) => /^\.(?:com|exe)$/i.test(extension));
|
|
1790
|
+
if (executableExts.length === 0) executableExts.push(".EXE", ".COM");
|
|
1791
|
+
const names = win32.extname(configured) !== "" ? [configured] : executableExts.map((extension) => configured + extension.toLowerCase());
|
|
1792
|
+
const hasPath = win32.isAbsolute(configured) || /[\\/]/.test(configured);
|
|
1793
|
+
const candidates = [];
|
|
1794
|
+
if (hasPath) candidates.push(...names);
|
|
1795
|
+
else {
|
|
1796
|
+
const path = windowsEnv(env, "PATH");
|
|
1797
|
+
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));
|
|
1798
|
+
const systemRoot = windowsEnv(env, "SystemRoot");
|
|
1799
|
+
if (systemRoot !== void 0 && systemRoot.trim() !== "") for (const name of names) candidates.push(win32.join(systemRoot, "System32", name));
|
|
1800
|
+
if (/^pwsh(?:\.exe)?$/i.test(configured)) for (const dir of windowsPwshCandidateDirs(env)) candidates.push(win32.join(dir, "pwsh.exe"));
|
|
1801
|
+
}
|
|
1802
|
+
for (const candidate of [...new Set(candidates)]) if (exists(candidate)) return candidate;
|
|
1803
|
+
throw notFound();
|
|
1804
|
+
}
|
|
1805
|
+
if (configured.includes("/")) {
|
|
1806
|
+
if (!exists(configured)) throw notFound();
|
|
1807
|
+
return configured;
|
|
1808
|
+
}
|
|
1809
|
+
const path = env.PATH ?? "/usr/bin:/bin";
|
|
1810
|
+
for (const dir of path.split(":").map((entry) => entry.trim()).filter(Boolean)) {
|
|
1811
|
+
const candidate = join(dir, configured);
|
|
1812
|
+
if (exists(candidate)) return candidate;
|
|
1813
|
+
}
|
|
1814
|
+
throw notFound();
|
|
1815
|
+
}
|
|
1816
|
+
/**
|
|
1605
1817
|
* The interactive shell for this platform, resolved like a terminal
|
|
1606
1818
|
* emulator: an explicitly configured shell (the `shell` config field) wins,
|
|
1607
1819
|
* then `$SHELL` on POSIX (deployment override), then the account's login
|
|
@@ -1663,6 +1875,20 @@ function shellSpawnArgs(configured = []) {
|
|
|
1663
1875
|
if (configured.length > 0) return [...configured];
|
|
1664
1876
|
return process.platform === "win32" ? [] : ["-l"];
|
|
1665
1877
|
}
|
|
1878
|
+
/**
|
|
1879
|
+
* Strip ONE pair of surrounding quotes from a configured shell path. Users
|
|
1880
|
+
* paste Windows paths with spaces pre-quoted (`"C:\Program Files\…"`); the
|
|
1881
|
+
* quotes are shell-input syntax, not part of the path. Unpaired quotes and
|
|
1882
|
+
* shorter values stay verbatim.
|
|
1883
|
+
*/
|
|
1884
|
+
function unquotePath(value) {
|
|
1885
|
+
if (value.length >= 2) {
|
|
1886
|
+
const first = value[0];
|
|
1887
|
+
const last = value[value.length - 1];
|
|
1888
|
+
if (first === "\"" && last === "\"" || first === "'" && last === "'") return value.slice(1, -1);
|
|
1889
|
+
}
|
|
1890
|
+
return value;
|
|
1891
|
+
}
|
|
1666
1892
|
//#endregion
|
|
1667
1893
|
//#region src/agent-pty.ts
|
|
1668
1894
|
/**
|
|
@@ -1698,6 +1924,61 @@ function clampDims(cols, rows) {
|
|
|
1698
1924
|
rows: clamp(rows)
|
|
1699
1925
|
};
|
|
1700
1926
|
}
|
|
1927
|
+
/**
|
|
1928
|
+
* node-pty's Windows terminal queues resize calls that arrive before the
|
|
1929
|
+
* ConPTY control socket's first data flush (`_deferNoArgs` in
|
|
1930
|
+
* windowsTerminal.js). If the pty exits before the queue flushes, the
|
|
1931
|
+
* deferred resize throws inside the socket's 'data' handler — uncatchable
|
|
1932
|
+
* by any caller and fatal to the host process. POSIX terminals have no such
|
|
1933
|
+
* queue (resize is synchronous), so the gate is armed on Windows only:
|
|
1934
|
+
* {@link tryResizePty} parks dims requested before the first output and
|
|
1935
|
+
* replays them after the flush, when node-pty executes resizes
|
|
1936
|
+
* synchronously (and the throw for an exited pty is catchable).
|
|
1937
|
+
*/
|
|
1938
|
+
const ptyResizeGates = /* @__PURE__ */ new WeakMap();
|
|
1939
|
+
/**
|
|
1940
|
+
* Arm the Windows pre-ready resize gate for one freshly spawned pty.
|
|
1941
|
+
* No-op on POSIX and for injected ptys without `onData`.
|
|
1942
|
+
*/
|
|
1943
|
+
function armPtyResizeGate(pty) {
|
|
1944
|
+
if (process.platform !== "win32") return;
|
|
1945
|
+
if (typeof pty.onData !== "function" || ptyResizeGates.has(pty)) return;
|
|
1946
|
+
const state = { sawData: false };
|
|
1947
|
+
ptyResizeGates.set(pty, state);
|
|
1948
|
+
pty.onData(() => {
|
|
1949
|
+
if (state.sawData) return;
|
|
1950
|
+
state.sawData = true;
|
|
1951
|
+
const dims = state.pending;
|
|
1952
|
+
state.pending = void 0;
|
|
1953
|
+
if (dims === void 0) return;
|
|
1954
|
+
setImmediate(() => {
|
|
1955
|
+
tryResizePty(pty, dims.cols, dims.rows);
|
|
1956
|
+
});
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
/**
|
|
1960
|
+
* Best-effort resize for WebSocket-driven terminal views. Layout animation
|
|
1961
|
+
* can briefly produce unusable dimensions, and node-pty can reject a resize
|
|
1962
|
+
* after the socket setup's outer try/catch has returned. Ignore that one
|
|
1963
|
+
* frame so the host stays alive and a later valid measurement can retry.
|
|
1964
|
+
* Returns whether node-pty accepted the resize (or parked it for replay on
|
|
1965
|
+
* the first output — the Windows pre-ready window).
|
|
1966
|
+
*/
|
|
1967
|
+
function tryResizePty(pty, cols, rows) {
|
|
1968
|
+
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return false;
|
|
1969
|
+
const dims = clampDims(cols, rows);
|
|
1970
|
+
const gate = ptyResizeGates.get(pty);
|
|
1971
|
+
if (gate !== void 0 && !gate.sawData) {
|
|
1972
|
+
gate.pending = dims;
|
|
1973
|
+
return true;
|
|
1974
|
+
}
|
|
1975
|
+
try {
|
|
1976
|
+
pty.resize(dims.cols, dims.rows);
|
|
1977
|
+
return true;
|
|
1978
|
+
} catch {
|
|
1979
|
+
return false;
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1701
1982
|
/** Map a POSIX signal number to its conventional name (best-effort). */
|
|
1702
1983
|
const SIGNAL_NAMES = {
|
|
1703
1984
|
1: "SIGHUP",
|
|
@@ -1720,20 +2001,54 @@ function signalNameOf(signal) {
|
|
|
1720
2001
|
if (signal === null || signal === void 0) return null;
|
|
1721
2002
|
return SIGNAL_NAMES[signal] ?? `signal ${signal}`;
|
|
1722
2003
|
}
|
|
1723
|
-
/**
|
|
1724
|
-
|
|
2004
|
+
/**
|
|
2005
|
+
* Compile a wait needle into a RegExp. The needle is treated as a JavaScript
|
|
2006
|
+
* regular expression; a pattern that fails to compile (e.g. an unbalanced
|
|
2007
|
+
* group typed as a literal) degrades to verbatim substring matching so
|
|
2008
|
+
* legacy literal needles keep working.
|
|
2009
|
+
*/
|
|
2010
|
+
function compileNeedle(needle) {
|
|
2011
|
+
try {
|
|
2012
|
+
return new RegExp(needle);
|
|
2013
|
+
} catch {
|
|
2014
|
+
return null;
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
/**
|
|
2018
|
+
* Locate the first occurrence of `needle` in `transcript`, returning its
|
|
2019
|
+
* line/column plus the actual matched text — for alternation patterns
|
|
2020
|
+
* (e.g. `(BUILD_OK|BUILD_FAIL)`) the match tells which alternative hit.
|
|
2021
|
+
* `re` is the precompiled form of `needle` (from {@link compileNeedle});
|
|
2022
|
+
* `null` means verbatim substring matching.
|
|
2023
|
+
*/
|
|
2024
|
+
function locateNeedle(transcript, needle, re) {
|
|
1725
2025
|
if (needle === "") return void 0;
|
|
1726
|
-
|
|
1727
|
-
if (
|
|
2026
|
+
let hit;
|
|
2027
|
+
if (re !== null) {
|
|
2028
|
+
re.lastIndex = 0;
|
|
2029
|
+
const m = re.exec(transcript);
|
|
2030
|
+
hit = m === null ? void 0 : {
|
|
2031
|
+
index: m.index,
|
|
2032
|
+
text: m[0]
|
|
2033
|
+
};
|
|
2034
|
+
} else {
|
|
2035
|
+
const idx = transcript.indexOf(needle);
|
|
2036
|
+
hit = idx === -1 ? void 0 : {
|
|
2037
|
+
index: idx,
|
|
2038
|
+
text: needle
|
|
2039
|
+
};
|
|
2040
|
+
}
|
|
2041
|
+
if (hit === void 0) return void 0;
|
|
1728
2042
|
let line = 0;
|
|
1729
2043
|
let lineStart = 0;
|
|
1730
|
-
for (let i = 0; i <
|
|
2044
|
+
for (let i = 0; i < hit.index; i += 1) if (transcript.charCodeAt(i) === 10) {
|
|
1731
2045
|
line += 1;
|
|
1732
2046
|
lineStart = i + 1;
|
|
1733
2047
|
}
|
|
1734
2048
|
return {
|
|
1735
2049
|
line,
|
|
1736
|
-
column:
|
|
2050
|
+
column: hit.index - lineStart,
|
|
2051
|
+
match: hit.text
|
|
1737
2052
|
};
|
|
1738
2053
|
}
|
|
1739
2054
|
/** Snapshot projection of a handle (drops the pty reference and transcript). */
|
|
@@ -1748,6 +2063,11 @@ function snapshotOf(handle) {
|
|
|
1748
2063
|
out.exitCode = handle.exitCode ?? null;
|
|
1749
2064
|
out.exitSignal = signalNameOf(handle.exitSignal);
|
|
1750
2065
|
}
|
|
2066
|
+
const active = handle.waits.at(-1);
|
|
2067
|
+
if (active !== void 0) out.waiting = {
|
|
2068
|
+
needle: active.needle,
|
|
2069
|
+
since: active.since
|
|
2070
|
+
};
|
|
1751
2071
|
return out;
|
|
1752
2072
|
}
|
|
1753
2073
|
/**
|
|
@@ -1779,13 +2099,15 @@ var AgentPtyRegistry = class {
|
|
|
1779
2099
|
create(sessionId, title, command, cwd, cols = 80, rows = 24, shell, shellArgs) {
|
|
1780
2100
|
const uuid = randomUUID();
|
|
1781
2101
|
const dims = clampDims(cols, rows);
|
|
1782
|
-
const
|
|
2102
|
+
const executable = resolveShellExecutable(shell ?? this.shell);
|
|
2103
|
+
const pty = this.nodePty.spawn(executable, shellSpawnArgs(shellArgs ?? this.shellArgs), {
|
|
1783
2104
|
name: "xterm-256color",
|
|
1784
2105
|
cols: dims.cols,
|
|
1785
2106
|
rows: dims.rows,
|
|
1786
2107
|
cwd,
|
|
1787
2108
|
env: { ...process.env }
|
|
1788
2109
|
});
|
|
2110
|
+
armPtyResizeGate(pty);
|
|
1789
2111
|
const handle = {
|
|
1790
2112
|
uuid,
|
|
1791
2113
|
sessionId,
|
|
@@ -1794,7 +2116,8 @@ var AgentPtyRegistry = class {
|
|
|
1794
2116
|
cwd,
|
|
1795
2117
|
pty,
|
|
1796
2118
|
transcript: "",
|
|
1797
|
-
exited: false
|
|
2119
|
+
exited: false,
|
|
2120
|
+
waits: []
|
|
1798
2121
|
};
|
|
1799
2122
|
pty.onData((data) => {
|
|
1800
2123
|
handle.transcript += data;
|
|
@@ -1878,7 +2201,7 @@ var AgentPtyRegistry = class {
|
|
|
1878
2201
|
resize(uuid, cols, rows) {
|
|
1879
2202
|
const handle = this.expect(uuid);
|
|
1880
2203
|
const dims = clampDims(cols, rows);
|
|
1881
|
-
if (!handle.exited) handle.pty
|
|
2204
|
+
if (!handle.exited) tryResizePty(handle.pty, dims.cols, dims.rows);
|
|
1882
2205
|
return dims;
|
|
1883
2206
|
}
|
|
1884
2207
|
/**
|
|
@@ -1901,10 +2224,17 @@ var AgentPtyRegistry = class {
|
|
|
1901
2224
|
* make event-driven wakeups unreliable. A 50ms poll is fast enough for
|
|
1902
2225
|
* interactive use and simple enough to be obviously correct.
|
|
1903
2226
|
* @param uuid - terminal to watch.
|
|
1904
|
-
* @param needle -
|
|
2227
|
+
* @param needle - JavaScript regular expression to search for
|
|
2228
|
+
* (case-sensitive); a pattern that fails to compile falls back to
|
|
2229
|
+
* verbatim substring matching. May cover several outcomes at once
|
|
2230
|
+
* (e.g. `(BUILD_OK|BUILD_FAIL)` for build success vs failure) — the
|
|
2231
|
+
* returned `match` reports the text that actually matched, so callers
|
|
2232
|
+
* can tell which outcome hit.
|
|
1905
2233
|
* @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms.
|
|
1906
2234
|
* @param signal - caller-owned cancellation; aborts the wait re-throwing.
|
|
1907
|
-
*
|
|
2235
|
+
* A wait can also be skipped by the user from the sidebar banner
|
|
2236
|
+
* (`skipWait`), which resolves it with `{kind:'skipped'}`.
|
|
2237
|
+
* @returns one of `found` / `timeout` / `exited` / `skipped`.
|
|
1908
2238
|
*/
|
|
1909
2239
|
async waitFor(uuid, needle, timeoutMs = 1e4, signal) {
|
|
1910
2240
|
if (needle === "") throw new SidebarError("bad-request", "needle must be a non-empty string", 400);
|
|
@@ -1912,47 +2242,83 @@ var AgentPtyRegistry = class {
|
|
|
1912
2242
|
const timeout = Math.max(100, Math.floor(timeoutMs));
|
|
1913
2243
|
const start = Date.now();
|
|
1914
2244
|
const deadline = start + timeout;
|
|
2245
|
+
const re = compileNeedle(needle);
|
|
1915
2246
|
if (handle.exited) return {
|
|
1916
2247
|
kind: "exited",
|
|
1917
2248
|
needle,
|
|
1918
2249
|
exitCode: handle.exitCode ?? null,
|
|
1919
2250
|
exitSignal: signalNameOf(handle.exitSignal)
|
|
1920
2251
|
};
|
|
1921
|
-
const firstHit = locateNeedle(handle.transcript, needle);
|
|
2252
|
+
const firstHit = locateNeedle(handle.transcript, needle, re);
|
|
1922
2253
|
if (firstHit !== void 0) return {
|
|
1923
2254
|
kind: "found",
|
|
1924
2255
|
needle,
|
|
1925
2256
|
line: firstHit.line,
|
|
1926
2257
|
column: firstHit.column,
|
|
2258
|
+
match: firstHit.match,
|
|
1927
2259
|
elapsedMs: Date.now() - start
|
|
1928
2260
|
};
|
|
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
|
-
|
|
2261
|
+
const record = {
|
|
2262
|
+
needle,
|
|
2263
|
+
since: start,
|
|
2264
|
+
skipped: false
|
|
2265
|
+
};
|
|
2266
|
+
handle.waits.push(record);
|
|
2267
|
+
this.notify();
|
|
2268
|
+
try {
|
|
2269
|
+
while (true) {
|
|
2270
|
+
if (signal?.aborted) signal.throwIfAborted();
|
|
2271
|
+
if (handle.exited) return {
|
|
2272
|
+
kind: "exited",
|
|
2273
|
+
needle,
|
|
2274
|
+
exitCode: handle.exitCode ?? null,
|
|
2275
|
+
exitSignal: signalNameOf(handle.exitSignal)
|
|
2276
|
+
};
|
|
2277
|
+
if (record.skipped) return {
|
|
2278
|
+
kind: "skipped",
|
|
2279
|
+
needle
|
|
2280
|
+
};
|
|
2281
|
+
const hit = locateNeedle(handle.transcript, needle, re);
|
|
2282
|
+
if (hit !== void 0) return {
|
|
2283
|
+
kind: "found",
|
|
2284
|
+
needle,
|
|
2285
|
+
line: hit.line,
|
|
2286
|
+
column: hit.column,
|
|
2287
|
+
match: hit.match,
|
|
2288
|
+
elapsedMs: Date.now() - start
|
|
2289
|
+
};
|
|
2290
|
+
if (Date.now() >= deadline) return {
|
|
2291
|
+
kind: "timeout",
|
|
2292
|
+
needle,
|
|
2293
|
+
timeoutMs: timeout,
|
|
2294
|
+
totalLines: handle.transcript.split("\n").length
|
|
2295
|
+
};
|
|
2296
|
+
await new Promise((resolve) => {
|
|
2297
|
+
const t = setTimeout(resolve, 50);
|
|
2298
|
+
if (typeof t === "object" && "unref" in t) t.unref();
|
|
2299
|
+
});
|
|
2300
|
+
}
|
|
2301
|
+
} finally {
|
|
2302
|
+
const index = handle.waits.indexOf(record);
|
|
2303
|
+
if (index !== -1) handle.waits.splice(index, 1);
|
|
2304
|
+
this.notify();
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
/**
|
|
2308
|
+
* Mark every active wait on one terminal as skipped (the sidebar banner's
|
|
2309
|
+
* skip button). Each waiting poll loop observes its record's flag within
|
|
2310
|
+
* one 50ms tick and returns `{kind:'skipped'}`. Idempotent: 0 when nothing
|
|
2311
|
+
* is waiting (a stale banner racing a wait that already resolved).
|
|
2312
|
+
* @returns the number of waits that transitioned to skipped.
|
|
2313
|
+
*/
|
|
2314
|
+
skipWait(uuid) {
|
|
2315
|
+
const handle = this.expect(uuid);
|
|
2316
|
+
let count = 0;
|
|
2317
|
+
for (const record of handle.waits) if (!record.skipped) {
|
|
2318
|
+
record.skipped = true;
|
|
2319
|
+
count += 1;
|
|
1955
2320
|
}
|
|
2321
|
+
return count;
|
|
1956
2322
|
}
|
|
1957
2323
|
/**
|
|
1958
2324
|
* Send a POSIX signal to a terminal's foreground process.
|
|
@@ -2327,7 +2693,7 @@ function registerTools(ctx, registry, resolveCwd, readShellOverrides) {
|
|
|
2327
2693
|
}));
|
|
2328
2694
|
register(defineTool({
|
|
2329
2695
|
name: "terminal_wait_for",
|
|
2330
|
-
description: "Block until a
|
|
2696
|
+
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
2697
|
parameters: {
|
|
2332
2698
|
uuid: {
|
|
2333
2699
|
type: "string",
|
|
@@ -2337,7 +2703,7 @@ function registerTools(ctx, registry, resolveCwd, readShellOverrides) {
|
|
|
2337
2703
|
needle: {
|
|
2338
2704
|
type: "string",
|
|
2339
2705
|
required: true,
|
|
2340
|
-
description: "
|
|
2706
|
+
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
2707
|
},
|
|
2342
2708
|
timeout_ms: {
|
|
2343
2709
|
type: "number",
|
|
@@ -2369,6 +2735,11 @@ function registerTools(ctx, registry, resolveCwd, readShellOverrides) {
|
|
|
2369
2735
|
required: true,
|
|
2370
2736
|
description: "0-based column index within that line where the match starts."
|
|
2371
2737
|
},
|
|
2738
|
+
match: {
|
|
2739
|
+
type: "string",
|
|
2740
|
+
required: true,
|
|
2741
|
+
description: "The text that actually matched — for multi-outcome patterns ( e.g. `(BUILD_OK|BUILD_FAIL)` ) this tells which alternative matched."
|
|
2742
|
+
},
|
|
2372
2743
|
elapsedMs: {
|
|
2373
2744
|
type: "integer",
|
|
2374
2745
|
required: true,
|
|
@@ -2423,18 +2794,40 @@ function registerTools(ctx, registry, resolveCwd, readShellOverrides) {
|
|
|
2423
2794
|
description: "Exit signal name, if killed by a signal."
|
|
2424
2795
|
}
|
|
2425
2796
|
}
|
|
2797
|
+
},
|
|
2798
|
+
{
|
|
2799
|
+
type: "object",
|
|
2800
|
+
additionalProperties: false,
|
|
2801
|
+
properties: {
|
|
2802
|
+
kind: {
|
|
2803
|
+
type: "string",
|
|
2804
|
+
required: true,
|
|
2805
|
+
const: "skipped"
|
|
2806
|
+
},
|
|
2807
|
+
needle: {
|
|
2808
|
+
type: "string",
|
|
2809
|
+
required: true
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2426
2812
|
}
|
|
2427
2813
|
] },
|
|
2428
2814
|
render: (_args, value) => {
|
|
2429
2815
|
const v = value;
|
|
2430
|
-
if (v.kind === "found")
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2816
|
+
if (v.kind === "found") {
|
|
2817
|
+
const matched = v.match !== void 0 && v.match !== "" ? `, matched "${v.match}"` : "";
|
|
2818
|
+
return [{
|
|
2819
|
+
type: "text",
|
|
2820
|
+
text: `Found "${v.needle}" at line ${v.line}, column ${v.column}${matched} (after ${v.elapsedMs}ms).`
|
|
2821
|
+
}];
|
|
2822
|
+
}
|
|
2434
2823
|
if (v.kind === "timeout") return [{
|
|
2435
2824
|
type: "text",
|
|
2436
2825
|
text: `Timed out after ${v.timeoutMs}ms waiting for "${v.needle}". Call terminal_read to inspect the transcript.`
|
|
2437
2826
|
}];
|
|
2827
|
+
if (v.kind === "skipped") return [{
|
|
2828
|
+
type: "text",
|
|
2829
|
+
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.`
|
|
2830
|
+
}];
|
|
2438
2831
|
const exitInfo = v.exitCode !== void 0 && v.exitCode !== null ? ` (exit code ${v.exitCode})` : "";
|
|
2439
2832
|
return [{
|
|
2440
2833
|
type: "text",
|
|
@@ -3505,12 +3898,13 @@ function buildSidechatApi(ctx) {
|
|
|
3505
3898
|
meta: {
|
|
3506
3899
|
...parentSession.header.cwd === void 0 ? {} : { cwd: parentSession.header.cwd },
|
|
3507
3900
|
parentSession: parentSession.id,
|
|
3508
|
-
isSeeded:
|
|
3901
|
+
isSeeded: true,
|
|
3509
3902
|
origin: "subagent",
|
|
3510
3903
|
delegationDepth: (parentSession.header.delegationDepth ?? 0) + 1,
|
|
3511
3904
|
...agentPreset === void 0 ? {} : { agentPreset }
|
|
3512
3905
|
},
|
|
3513
3906
|
seed,
|
|
3907
|
+
inheritedEventCount: SessionLogOffset(seed.length),
|
|
3514
3908
|
agentOptions: { ...parent.options },
|
|
3515
3909
|
setup,
|
|
3516
3910
|
signal: AbortSignal.timeout(CREATE_TIMEOUT_MS)
|
|
@@ -3857,6 +4251,21 @@ function buildApi(ctx, ptyManager, agentPtyRegistry, resolved, terminalShell, ge
|
|
|
3857
4251
|
}
|
|
3858
4252
|
return { ok: true };
|
|
3859
4253
|
},
|
|
4254
|
+
"fs.rename": async (payload) => {
|
|
4255
|
+
const { cwd } = await cwdOf(payload);
|
|
4256
|
+
return renameWorkspaceEntry({
|
|
4257
|
+
cwd,
|
|
4258
|
+
path: requireString(payload, "path"),
|
|
4259
|
+
name: requireString(payload, "name")
|
|
4260
|
+
});
|
|
4261
|
+
},
|
|
4262
|
+
"fs.remove": async (payload) => {
|
|
4263
|
+
const { cwd } = await cwdOf(payload);
|
|
4264
|
+
return removeWorkspaceEntry({
|
|
4265
|
+
cwd,
|
|
4266
|
+
path: requireString(payload, "path")
|
|
4267
|
+
});
|
|
4268
|
+
},
|
|
3860
4269
|
"git.worktrees": async (payload) => {
|
|
3861
4270
|
const { cwd } = await gitCwdOf(payload);
|
|
3862
4271
|
const selected = selectedRepoOf(payload);
|
|
@@ -3927,6 +4336,15 @@ function buildApi(ctx, ptyManager, agentPtyRegistry, resolved, terminalShell, ge
|
|
|
3927
4336
|
const path = await resolveGitPath(cwd, requireString(payload, "path"), repoRoot);
|
|
3928
4337
|
return { content: await show(cwd, requireString(payload, "rev"), path, repoRoot) };
|
|
3929
4338
|
},
|
|
4339
|
+
"git.fold-contents": async (payload) => {
|
|
4340
|
+
const { cwd } = await gitCwdOf(payload);
|
|
4341
|
+
const repoRoot = selectedRepoOf(payload);
|
|
4342
|
+
const record = payload;
|
|
4343
|
+
return await foldContents(cwd, await resolveGitPath(cwd, requireString(record, "path"), repoRoot), {
|
|
4344
|
+
staged: record.staged === true,
|
|
4345
|
+
hash: typeof record.hash === "string" ? record.hash : void 0
|
|
4346
|
+
}, repoRoot);
|
|
4347
|
+
},
|
|
3930
4348
|
"pty.close": (payload) => {
|
|
3931
4349
|
const sessionId = requireString(payload, "sessionId");
|
|
3932
4350
|
const tab = requireString(payload, "tab");
|
|
@@ -3938,6 +4356,13 @@ function buildApi(ctx, ptyManager, agentPtyRegistry, resolved, terminalShell, ge
|
|
|
3938
4356
|
agentPtyRegistry?.close(uuid);
|
|
3939
4357
|
return { ok: true };
|
|
3940
4358
|
},
|
|
4359
|
+
"agent-pty.skip-wait": (payload) => {
|
|
4360
|
+
const uuid = requireString(payload, "uuid");
|
|
4361
|
+
return {
|
|
4362
|
+
ok: true,
|
|
4363
|
+
skipped: agentPtyRegistry?.skipWait(uuid) ?? 0
|
|
4364
|
+
};
|
|
4365
|
+
},
|
|
3941
4366
|
"terminal.deps": () => depsStatus(),
|
|
3942
4367
|
"jobs.output": (payload) => jobsApi.output(payload),
|
|
3943
4368
|
"jobs.kill": (payload) => jobsApi.kill(payload),
|
|
@@ -4340,6 +4765,33 @@ async function attachAgentList(registry, ws, req) {
|
|
|
4340
4765
|
}
|
|
4341
4766
|
}
|
|
4342
4767
|
/**
|
|
4768
|
+
* The WS close reason for a failed terminal attach. A missing configured
|
|
4769
|
+
* shell gets a SHORT machine-readable marker (`shell-not-found:<name>`,
|
|
4770
|
+
* capped by BYTES — a WS close reason allows at most 123 bytes, which `ws`
|
|
4771
|
+
* validates with `Buffer.byteLength`) that the client maps to a localized,
|
|
4772
|
+
* actionable banner; every other failure keeps the raw message (the
|
|
4773
|
+
* model-side tool errors read it verbatim).
|
|
4774
|
+
*/
|
|
4775
|
+
function wsCloseReasonOf(error) {
|
|
4776
|
+
if (error instanceof SidebarError && error.code === "shell-not-found") return `shell-not-found:${truncateUtf8Bytes(shellDisplayName(String(error.meta?.shell ?? "")), 100)}`;
|
|
4777
|
+
return error instanceof Error ? error.message : String(error);
|
|
4778
|
+
}
|
|
4779
|
+
/**
|
|
4780
|
+
* Truncate to at most `maxBytes` UTF-8 bytes without splitting a code point.
|
|
4781
|
+
* A character-count `slice` does not bound the WS close reason: `ws` measures
|
|
4782
|
+
* `Buffer.byteLength` against its 123-byte cap, and the resulting throw would
|
|
4783
|
+
* replace the very error the reason describes.
|
|
4784
|
+
*/
|
|
4785
|
+
function truncateUtf8Bytes(value, maxBytes) {
|
|
4786
|
+
if (Buffer.byteLength(value) <= maxBytes) return value;
|
|
4787
|
+
let truncated = "";
|
|
4788
|
+
for (const character of value) {
|
|
4789
|
+
if (Buffer.byteLength(truncated + character) > maxBytes) break;
|
|
4790
|
+
truncated += character;
|
|
4791
|
+
}
|
|
4792
|
+
return truncated;
|
|
4793
|
+
}
|
|
4794
|
+
/**
|
|
4343
4795
|
* Wire one terminal socket to its pty: replay transcript, pump both ways.
|
|
4344
4796
|
* Two attach modes share the wire protocol:
|
|
4345
4797
|
* - `?uuid=...` attaches to an agent-owned terminal (created by the
|
|
@@ -4385,6 +4837,7 @@ async function attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolv
|
|
|
4385
4837
|
const cwd = await sessionCwdOf(ctx, sessionId, url.searchParams.get("cwd") ?? void 0);
|
|
4386
4838
|
const overrides = shellOverridesOf(getSettings);
|
|
4387
4839
|
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24, overrides.shell, overrides.shellArgs);
|
|
4840
|
+
armPtyResizeGate(handle.pty);
|
|
4388
4841
|
if (handle.transcript !== "") ws.send(handle.transcript);
|
|
4389
4842
|
const onData = (data) => {
|
|
4390
4843
|
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4194304) ws.send(data);
|
|
@@ -4410,10 +4863,8 @@ async function attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolv
|
|
|
4410
4863
|
return;
|
|
4411
4864
|
}
|
|
4412
4865
|
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);
|
|
4866
|
+
if (control !== null && control.type === "resize" && typeof control.cols === "number" && typeof control.rows === "number") tryResizePty(handle.pty, control.cols, control.rows);
|
|
4867
|
+
else handle.pty.write(text);
|
|
4417
4868
|
});
|
|
4418
4869
|
ws.on("close", () => {
|
|
4419
4870
|
dataSub.dispose();
|
|
@@ -4421,7 +4872,7 @@ async function attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolv
|
|
|
4421
4872
|
if (!ptyManager.isParked(handle.key)) ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs);
|
|
4422
4873
|
});
|
|
4423
4874
|
} catch (error) {
|
|
4424
|
-
ws.close(1011,
|
|
4875
|
+
ws.close(1011, wsCloseReasonOf(error));
|
|
4425
4876
|
}
|
|
4426
4877
|
}
|
|
4427
4878
|
/**
|
|
@@ -4453,10 +4904,8 @@ function pumpAgentTerminal(registry, handle, ws) {
|
|
|
4453
4904
|
registry.close(handle.uuid);
|
|
4454
4905
|
return;
|
|
4455
4906
|
}
|
|
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);
|
|
4907
|
+
if (control !== null && control.type === "resize" && typeof control.cols === "number" && typeof control.rows === "number") tryResizePty(handle.pty, control.cols, control.rows);
|
|
4908
|
+
else if (control === null) handle.pty.write(text);
|
|
4460
4909
|
});
|
|
4461
4910
|
ws.on("close", () => {
|
|
4462
4911
|
dataSub.dispose();
|
|
@@ -4464,4 +4913,4 @@ function pumpAgentTerminal(registry, handle, ws) {
|
|
|
4464
4913
|
});
|
|
4465
4914
|
}
|
|
4466
4915
|
//#endregion
|
|
4467
|
-
export { Config, apply, inject, mediaTypeForPath, name };
|
|
4916
|
+
export { Config, apply, inject, mediaTypeForPath, name, wsCloseReasonOf };
|