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.
Files changed (81) hide show
  1. package/README.md +2 -2
  2. package/lib/client-editor.js +485 -228
  3. package/lib/client-registry.js +1238 -7583
  4. package/lib/client-terminal.js +345 -189
  5. package/lib/client.js +1213 -7558
  6. package/lib/index.js +518 -69
  7. package/lib/types/agent-pty.d.ts +62 -4
  8. package/lib/types/bundle-route.d.ts +1 -1
  9. package/lib/types/client/DiffView.d.ts +33 -1
  10. package/lib/types/client/EditorHost.d.ts +4 -1
  11. package/lib/types/client/FileTree.d.ts +6 -2
  12. package/lib/types/client/TerminalWaitBanner.d.ts +6 -0
  13. package/lib/types/client/TreePanel.d.ts +4 -1
  14. package/lib/types/client/api.d.ts +26 -0
  15. package/lib/types/client/chunk-loader.d.ts +1 -1
  16. package/lib/types/client/chunks/locale.d.ts +3 -0
  17. package/lib/types/client/conversation-draft.d.ts +85 -4
  18. package/lib/types/client/locales.d.ts +13 -20
  19. package/lib/types/client/selection-popup.d.ts +58 -0
  20. package/lib/types/client/service.d.ts +1 -1
  21. package/lib/types/client/state.d.ts +15 -0
  22. package/lib/types/client/terminal-font.d.ts +25 -2
  23. package/lib/types/context-types.d.ts +3 -1
  24. package/lib/types/fs-operations.d.ts +42 -0
  25. package/lib/types/git.d.ts +15 -0
  26. package/lib/types/index.d.ts +9 -0
  27. package/lib/types/prefs-shared.d.ts +7 -5
  28. package/lib/types/pty-manager.d.ts +53 -0
  29. package/lib/types/wire.d.ts +6 -2
  30. package/package.json +1 -1
  31. package/src/agent-pty.ts +221 -33
  32. package/src/bundle-route.ts +1 -1
  33. package/src/client/DiffTab.tsx +10 -1
  34. package/src/client/DiffView.tsx +174 -19
  35. package/src/client/EditorHost.tsx +9 -2
  36. package/src/client/FileTree.tsx +151 -8
  37. package/src/client/Sidebar.tsx +95 -25
  38. package/src/client/TerminalView.tsx +41 -0
  39. package/src/client/TerminalWaitBanner.tsx +32 -0
  40. package/src/client/TextEditor.tsx +27 -45
  41. package/src/client/TreePanel.tsx +22 -2
  42. package/src/client/api.ts +20 -0
  43. package/src/client/chunk-loader.ts +1 -1
  44. package/src/client/chunks/locale.tsx +60 -0
  45. package/src/client/conversation-draft.ts +233 -7
  46. package/src/client/index.tsx +19 -8
  47. package/src/client/locales-ar.ts +17 -4
  48. package/src/client/locales-de.ts +17 -4
  49. package/src/client/locales-fr.ts +17 -4
  50. package/src/client/locales-hi.ts +17 -4
  51. package/src/client/locales-id.ts +17 -4
  52. package/src/client/locales-it.ts +17 -4
  53. package/src/client/locales-ja.ts +17 -4
  54. package/src/client/locales-ko.ts +17 -4
  55. package/src/client/locales-nl.ts +17 -4
  56. package/src/client/locales-pl.ts +17 -4
  57. package/src/client/locales-pt.ts +17 -4
  58. package/src/client/locales-ru.ts +17 -4
  59. package/src/client/locales-sv.ts +17 -4
  60. package/src/client/locales-th.ts +17 -4
  61. package/src/client/locales-tr.ts +17 -4
  62. package/src/client/locales-vi.ts +17 -4
  63. package/src/client/locales-zh-HK.ts +17 -4
  64. package/src/client/locales-zh-MO.ts +17 -4
  65. package/src/client/locales-zh-TW.ts +17 -4
  66. package/src/client/locales.ts +41 -51
  67. package/src/client/selection-popup.ts +155 -0
  68. package/src/client/service.ts +1 -1
  69. package/src/client/sidebar.module.css +68 -0
  70. package/src/client/state.ts +56 -10
  71. package/src/client/terminal-font.ts +34 -3
  72. package/src/context-types.ts +3 -2
  73. package/src/fs-operations.ts +126 -4
  74. package/src/git.ts +56 -3
  75. package/src/index.ts +82 -7
  76. package/src/open-external.ts +3 -4
  77. package/src/prefs-shared.ts +7 -5
  78. package/src/pty-manager.ts +176 -5
  79. package/src/sidechat-routes.ts +13 -1
  80. package/src/tools.ts +24 -6
  81. 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
- constructor(code, message, status = 400) {
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 (the upload route today).
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 = ["terminal", "editor"];
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: ["/select,", path]
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
- return parseWorktreeList(await runGit(cwd, [
1166
+ let raw;
1167
+ if (worktreeListSupportsZ === false) raw = await runGit(cwd, [
1068
1168
  "worktree",
1069
1169
  "list",
1070
- "--porcelain",
1071
- "-z"
1072
- ])).filter((entry) => !entry.prunable);
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.PATH;
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.ProgramW6432, env.ProgramFiles]) {
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.LOCALAPPDATA;
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
- /** Locate the first occurrence of `needle` in `transcript`, returning its line/column. */
1724
- function locateNeedle(transcript, needle) {
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
- const idx = transcript.indexOf(needle);
1727
- if (idx === -1) return void 0;
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 < idx; i += 1) if (transcript.charCodeAt(i) === 10) {
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: idx - lineStart
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 pty = this.nodePty.spawn(shell ?? this.shell, shellSpawnArgs(shellArgs ?? this.shellArgs), {
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.resize(dims.cols, dims.rows);
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 - substring to search for (case-sensitive, verbatim).
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
- * @returns one of `found` / `timeout` / `exited`.
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
- while (true) {
1930
- if (signal?.aborted) signal.throwIfAborted();
1931
- if (handle.exited) return {
1932
- kind: "exited",
1933
- needle,
1934
- exitCode: handle.exitCode ?? null,
1935
- exitSignal: signalNameOf(handle.exitSignal)
1936
- };
1937
- const hit = locateNeedle(handle.transcript, needle);
1938
- if (hit !== void 0) return {
1939
- kind: "found",
1940
- needle,
1941
- line: hit.line,
1942
- column: hit.column,
1943
- elapsedMs: Date.now() - start
1944
- };
1945
- if (Date.now() >= deadline) return {
1946
- kind: "timeout",
1947
- needle,
1948
- timeoutMs: timeout,
1949
- totalLines: handle.transcript.split("\n").length
1950
- };
1951
- await new Promise((resolve) => {
1952
- const t = setTimeout(resolve, 50);
1953
- if (typeof t === "object" && "unref" in t) t.unref();
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 substring 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 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 of the first occurrence, `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.",
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: "Substring to wait for (case-sensitive, verbatim). Must be non-empty."
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") return [{
2431
- type: "text",
2432
- text: `Found "${v.needle}" at line ${v.line}, column ${v.column} (after ${v.elapsedMs}ms).`
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: seed.length > 0,
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
- const dims = clampDims(control.cols, control.rows);
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, error instanceof Error ? error.message : String(error));
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
- const dims = clampDims(control.cols, control.rows);
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 };