@akira-tl/forgerelay 0.10.2 → 0.10.3

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/CHANGELOG.md CHANGED
@@ -4,6 +4,17 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.10.3] - 2026-09-06
8
+
9
+ ### Added
10
+
11
+ - Added first-class `cmd.exe` execution for Agent commands and Hooks with explicit cmd shell identity, editable cmd Instructions, Windows-native quoting/escaping guidance, and package-level `.cmd` launcher acceptance.
12
+ - Added Windows cmd PTY/ConPTY lifecycle coverage for stdin, resize, background completion, timeout, interrupt, durable output, Unicode, exit codes, and child-process-tree cleanup without introducing a separate Windows Process Manager.
13
+
14
+ ### Fixed
15
+
16
+ - Preserved raw `cmd.exe /S /C` command-line quoting through node-pty by using the Windows pre-escaped CommandLine path for interactive cmd sessions, preventing ConPTY from rewriting quoted commands and exit semantics.
17
+
7
18
  ## [0.10.2] - 2026-09-06
8
19
 
9
20
  ### Added
package/README.md CHANGED
@@ -295,9 +295,10 @@ See [Security Model](docs/security.md) for the full boundary and threat model.
295
295
  | --- | --- | --- |
296
296
  | Linux | Supported | Requires Node, npm, Git, and Bash. |
297
297
  | macOS | Supported | Requires Node, npm, Git, and Bash. |
298
- | Windows with PowerShell 7 (`pwsh`) | Supported | Agent commands and Hooks use one native `pwsh` runtime without loading the user profile. |
298
+ | Windows with PowerShell 7 (`pwsh`) | Supported | Agent commands, Hooks, and PTY execution use one native `pwsh` runtime without loading the user profile. |
299
+ | Windows with Windows PowerShell 5.1 (`powershell.exe`) | Supported | Agent commands, Hooks, and PTY execution use the selected 5.1 runtime with 5.1-specific syntax guidance. |
300
+ | Windows with `cmd.exe` | Supported for Agent commands and Hooks | ForgeRelay preserves cmd syntax and reports the selected runtime explicitly; PTY/packaged-launcher lifecycle coverage is completed separately. |
299
301
  | Windows with Git Bash, WSL, MSYS2, or Cygwin Bash | Supported | Bash remains available as a compatibility path. |
300
- | Windows PowerShell 5.1 (`powershell.exe`) or `cmd.exe` only | Not supported yet | These runtimes are separate follow-up stages. |
301
302
 
302
303
  You can check the local runtime with:
303
304
 
@@ -48,6 +48,6 @@ PTY 依赖 optional `node-pty`。缺少该依赖时 ForgeRelay 会明确报错
48
48
 
49
49
  ## Platform notes
50
50
 
51
- ForgeRelay 在 Windows 上支持 PowerShell 7 (`pwsh`) 作为原生 Command Shell Runtime;非 TTY Agent 命令与 Hooks 使用同一个 `pwsh`,禁止加载用户 profile,PTY 也复用同一 executable。公共工具名仍保留为 `bash` 以兼容 Host contract,实际命令语言以 ForgeRelay 报告的 Command Shell Runtime 为准。Windows PowerShell 5.1 (`powershell.exe`)`cmd.exe` 仍属于后续 runtime 阶段;Git Bash、WSL、MSYS2 或 Cygwin Bash 仍可作为 Bash 兼容路径。用 `forgerelay doctor` 检查当前 shell identity、version 与 executable。
51
+ ForgeRelay 在 Windows 上原生支持 PowerShell 7 (`pwsh`) Windows PowerShell 5.1 (`powershell.exe`) 的 Agent、Hook 和 PTY 执行,也支持 `cmd.exe` 作为 Agent 命令与 Hook 的一等 Command Shell RuntimeAgent 命令与 Hooks 始终复用已选择的 runtime,不会为了兼容性静默切换到 Bash PowerShell;PowerShell runtime 不加载用户 profile。公共工具名仍保留为 `bash` 以兼容 Host contract,实际命令语言以 ForgeRelay 报告的 Command Shell Runtime 为准。`cmd.exe` 的 PTY/ConPTY packaged `.cmd` launcher lifecycle 属于下一阶段验收,不应由本阶段的非 TTY 支持推断。Git Bash、WSL、MSYS2 或 Cygwin Bash 仍可作为 Bash 兼容路径。用 `forgerelay doctor` 检查当前 shell identity、version 与 executable。
52
52
 
53
53
  Shell 可以作为用户开发任务的一部分修改普通项目文件,但始终受 ForgeRelay core mutation/safety contract 约束。涉及 privileged OS files、credentials、configuration 或外部硬件持久写入时,不要用本指南替代 core authorization/safety 规则。
@@ -27,6 +27,10 @@ export function resolveShellCommandForRuntime(command, runtime, options = {}) {
27
27
  // cmd.exe /S applies special quote stripping, so the whole command must
28
28
  // be wrapped even when the executable inside it is already quoted.
29
29
  args: ["/d", "/s", "/c", `"${command}"`],
30
+ // node-pty accepts a pre-escaped Windows CommandLine string. Passing the
31
+ // cmd arguments as string[] makes node-pty escape embedded quotes using
32
+ // CommandLineToArgvW rules, which changes cmd.exe /S /C semantics.
33
+ ...(options.interactive ? { ptyCommandLine: `/d /s /c "${command}"` } : {}),
30
34
  windowsVerbatimArguments: true,
31
35
  };
32
36
  case "bash":
@@ -470,7 +470,7 @@ export class ProcessManager {
470
470
  throw new Error("PTY support requires the optional node-pty dependency.");
471
471
  }
472
472
  const shell = resolveShellCommandForRuntime(input.command, this.commandShellRuntime, { interactive: true });
473
- const pty = nodePty.spawn(shell.executable, shell.args, {
473
+ const pty = nodePty.spawn(shell.executable, shell.ptyCommandLine ?? shell.args, {
474
474
  cwd: input.cwd,
475
475
  env: processEnvironment({
476
476
  workspaceId: input.workspaceId,
@@ -203,7 +203,9 @@ export function commandShellAgentInstruction(runtime) {
203
203
  "Write shell commands for this runtime rather than assuming Bash syntax.",
204
204
  ...(runtime.family === "powershell"
205
205
  ? ["Windows PowerShell 5.1 does not support PowerShell 7 pipeline-chain operators && or ||."]
206
- : []),
206
+ : runtime.family === "cmd"
207
+ ? ["cmd.exe uses %NAME% environment expansion and %ERRORLEVEL% process status. Do not emit PowerShell or Bash syntax unless explicitly launching those shells as child processes."]
208
+ : []),
207
209
  ].join(" ");
208
210
  }
209
211
  function runtimeFromSelection(selection, source) {
package/docs/gotchas.md CHANGED
@@ -162,10 +162,7 @@ ForgeRelay does not intentionally leave the source checkout in a merge-conflict
162
162
 
163
163
  ## Windows shell commands fail
164
164
 
165
- ForgeRelay supports PowerShell 7 (`pwsh`) as a native Windows Command Shell Runtime.
166
- Agent commands and Hooks share that runtime and do not load the user PowerShell profile.
167
- Windows PowerShell 5.1 (`powershell.exe`) and `cmd.exe` remain separate follow-up runtimes.
168
- Git Bash, WSL, MSYS2, and Cygwin Bash also remain supported compatibility paths.
165
+ ForgeRelay supports PowerShell 7 (`pwsh`) and Windows PowerShell 5.1 (`powershell.exe`) as native Windows Command Shell Runtimes. Agent commands and Hooks share the selected runtime and PowerShell execution does not load the user profile. `cmd.exe` is also a first-class Agent/Hook runtime: use `%NAME%`, `%ERRORLEVEL%`, cmd quoting/chaining/redirection, and `^` escaping rather than silently substituting PowerShell or Bash syntax. cmd PTY/ConPTY and packaged `.cmd` launcher lifecycle coverage is completed separately. Git Bash, WSL, MSYS2, and Cygwin Bash remain supported compatibility paths.
169
166
 
170
167
  Check the selected runtime with:
171
168
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akira-tl/forgerelay",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
4
4
  "description": "Local development control plane for MCP coding agents.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Akira-TL/forgerelay#readme",
@@ -44,6 +44,7 @@
44
44
  "lsp:interop": "node scripts/lsp/interop.mjs",
45
45
  "pwsh:accept": "node scripts/ci/pwsh-acceptance.mjs",
46
46
  "powershell51:accept": "node scripts/ci/powershell51-acceptance.mjs",
47
+ "cmd:accept": "node scripts/ci/cmd-acceptance.mjs",
47
48
  "wiki:check": "node scripts/wiki/sync.mjs check",
48
49
  "wiki:publish": "node scripts/wiki/sync.mjs publish",
49
50
  "architecture:check": "node scripts/ci/architecture.mjs",
@@ -54,7 +55,7 @@
54
55
  "release:push-ready": "node scripts/release/push-ready.mjs",
55
56
  "postinstall": "node scripts/fix-node-pty-permissions.mjs",
56
57
  "start": "node dist/cli.js serve",
57
- "test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/push-ready.test.mjs scripts/release/release-version.test.mjs && tsx src/mcp/oauth/router.test.ts && tsx src/workspaces/relay/auth/remote-auth-cli.test.ts && tsx src/workspaces/relay/auth/remote-ssh-auth-cli.test.ts && tsx src/workspaces/relay/tests/lifecycle.test.ts && tsx src/workspaces/relay/tests/routing.test.ts && tsx src/workspaces/relay/tests/ssh.test.ts && tsx src/workspaces/relay/tests/process.test.ts && tsx src/workspaces/relay/tests/recovery.test.ts && tsx src/workspaces/relay/tests/checkpoint.test.ts && tsx src/runtime/config/config.test.ts && tsx src/runtime/instructions/powershell-skill.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/runtime/managed-language-servers.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/runtime/logging/logger.test.ts && tsx src/runtime/logging/proxy-trust.test.ts && tsx src/runtime/state/lock/file-lock.test.ts && tsx src/runtime/state/runtime-lease.test.ts && tsx src/mcp/panel/mcp-app-template.test.ts && tsx src/mcp/hooks/hooks.test.ts && tsx src/mcp/server/core/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/mcp/request-meta.test.ts && tsx src/mcp/artifacts/incoming-artifacts.test.ts && tsx src/mcp/artifacts/artifact-download.test.ts && tsx src/ui/core/card-types.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/activity/detail-card.test.ts && tsx src/ui/review/patch-display.test.ts && tsx src/ui/core/tool-display.test.ts && tsx src/mcp/filesystem/apply-patch.test.ts && tsx src/mcp/process/process-platform.test.ts && tsx src/mcp/process/process-sessions.test.ts && tsx src/mcp/server/transport/mcp-sessions.test.ts && tsx src/mcp/server/transport/server-shutdown.test.ts && tsx src/mcp/server/operations/mutation-diagnostics.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/adapters/pi.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/mcp/filesystem/roots.test.ts && tsx src/mcp/filesystem/file-mutations.test.ts && tsx src/mcp/operations/edit-preflight.test.ts && tsx src/workspaces/resources/skills.test.ts && tsx src/runtime/state/db/migrations.test.ts && tsx src/workspaces/state/workspace-store.test.ts && tsx src/workspaces/tasks/workspace-tasks.test.ts && tsx src/workspaces/tasks/workspace-task-reminders.test.ts && tsx src/activity/history/audit-store.test.ts && tsx src/activity/history/bash-output-store.test.ts && tsx src/activity/runtime/lifecycle.test.ts && tsx src/activity/history/query-service.test.ts && tsx src/mcp/operations/core-operation-executor.test.ts && tsx src/mcp/operations/bulk-mutation.test.ts && tsx src/mcp/operations/batch/scheduler.test.ts && tsx src/mcp/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspaces/conversation-checkout.test.ts && tsx src/workspaces/conversation-worktree.test.ts && tsx src/workspaces/git/worktree-recovery.test.ts && tsx src/mcp/server/workspace/workspace-inventory.test.ts && tsx src/mcp/server/workspace/workspace-recovery.test.ts && tsx src/mcp/server/workspace/workspace-checkpoint.test.ts && tsx src/workspaces/review/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/mcp/process/server.test.ts && tsx src/mcp/panel/server.test.ts && tsx src/mcp/server/server.test.ts && tsx src/mcp/oauth/oauth-store.test.ts && tsx src/cli/maintenance.test.ts && tsx src/cli/maintenance-prune.test.ts && tsx src/cli/cli.test.ts",
58
+ "test": "node --test scripts/debug/runtime.test.mjs scripts/release-proof.test.mjs scripts/release/release-gate.test.mjs scripts/release/push-ready.test.mjs scripts/release/release-version.test.mjs && tsx src/mcp/oauth/router.test.ts && tsx src/workspaces/relay/auth/remote-auth-cli.test.ts && tsx src/workspaces/relay/auth/remote-ssh-auth-cli.test.ts && tsx src/workspaces/relay/tests/lifecycle.test.ts && tsx src/workspaces/relay/tests/routing.test.ts && tsx src/workspaces/relay/tests/ssh.test.ts && tsx src/workspaces/relay/tests/process.test.ts && tsx src/workspaces/relay/tests/recovery.test.ts && tsx src/workspaces/relay/tests/checkpoint.test.ts && tsx src/runtime/config/config.test.ts && tsx src/runtime/shell/command-shell-runtime.test.ts && tsx src/runtime/instructions/shell-instructions.test.ts && tsx src/runtime/instructions/powershell-skill.test.ts && tsx src/cli/shell/setup.test.ts && tsx src/lsp/language-server-config.test.ts && tsx src/lsp/runtime/managed-language-servers.test.ts && tsx src/lsp/normalization/hover.test.ts && tsx src/lsp/references.server.test.ts && tsx src/lsp/operations/document-symbols.server.test.ts && tsx src/lsp/operations/workspace-symbols.server.test.ts && tsx src/lsp/operations/diagnostics-push.server.test.ts && tsx src/lsp/operations/diagnostics-pull.server.test.ts && tsx src/lsp/operations/request-hardening.server.test.ts && tsx src/lsp/operations/recovery.server.test.ts && tsx src/lsp/operations/lifecycle.server.test.ts && tsx src/lsp/runtime/semantic-requests.test.ts && tsx src/runtime/logging/logger.test.ts && tsx src/runtime/logging/proxy-trust.test.ts && tsx src/runtime/state/lock/file-lock.test.ts && tsx src/runtime/state/runtime-lease.test.ts && tsx src/mcp/panel/mcp-app-template.test.ts && tsx src/mcp/hooks/hooks.test.ts && tsx src/mcp/server/core/capability-registry.test.ts && tsx src/mcp/server-instructions.test.ts && tsx src/mcp/request-meta.test.ts && tsx src/mcp/artifacts/incoming-artifacts.test.ts && tsx src/mcp/artifacts/artifact-download.test.ts && tsx src/ui/core/card-types.test.ts && tsx src/ui/activity/model.test.ts && tsx src/ui/activity/detail-card.test.ts && tsx src/ui/review/patch-display.test.ts && tsx src/ui/core/tool-display.test.ts && tsx src/mcp/filesystem/apply-patch.test.ts && tsx src/mcp/process/process-platform.test.ts && tsx src/mcp/process/process-sessions.test.ts && tsx src/mcp/server/transport/mcp-sessions.test.ts && tsx src/mcp/server/transport/server-shutdown.test.ts && tsx src/mcp/server/operations/mutation-diagnostics.test.ts && tsx src/mcp/server/operations/hooks.test.ts && tsx src/subagents/providers/adapters/codex.test.ts && tsx src/subagents/providers/adapters/pi.test.ts && tsx src/subagents/providers/registry.test.ts && tsx src/subagents/providers/availability.test.ts && tsx src/subagents/profiles.test.ts && tsx src/subagents/cli-target.test.ts && tsx src/subagents/sessions/store.test.ts && tsx src/subagents/sessions/manager.test.ts && tsx src/subagents/sessions/mcp/capability.server.test.ts && tsx src/subagents/sessions/mcp/continuation.server.test.ts && tsx src/subagents/sessions/mcp/lifecycle.server.test.ts && tsx src/subagents/sessions/mcp/reconciliation.server.test.ts && tsx src/subagents/sessions/mcp/routing.server.test.ts && tsx src/mcp/filesystem/roots.test.ts && tsx src/mcp/filesystem/file-mutations.test.ts && tsx src/mcp/operations/edit-preflight.test.ts && tsx src/workspaces/resources/skills.test.ts && tsx src/runtime/state/db/migrations.test.ts && tsx src/workspaces/state/workspace-store.test.ts && tsx src/workspaces/tasks/workspace-tasks.test.ts && tsx src/workspaces/tasks/workspace-task-reminders.test.ts && tsx src/activity/history/audit-store.test.ts && tsx src/activity/history/bash-output-store.test.ts && tsx src/activity/runtime/lifecycle.test.ts && tsx src/activity/history/query-service.test.ts && tsx src/mcp/operations/core-operation-executor.test.ts && tsx src/mcp/operations/bulk-mutation.test.ts && tsx src/mcp/operations/batch/scheduler.test.ts && tsx src/mcp/operations/batch/executor-policy.test.ts && tsx src/workspaces.test.ts && tsx src/workspaces/conversation-checkout.test.ts && tsx src/workspaces/conversation-worktree.test.ts && tsx src/workspaces/git/worktree-recovery.test.ts && tsx src/mcp/server/workspace/workspace-inventory.test.ts && tsx src/mcp/server/workspace/workspace-recovery.test.ts && tsx src/mcp/server/workspace/workspace-checkpoint.test.ts && tsx src/workspaces/review/review-checkpoints.test.ts && tsx src/lsp/code-intelligence.server.test.ts && npm run build:app && tsx src/mcp/process/server.test.ts && tsx src/mcp/panel/server.test.ts && tsx src/mcp/server/server.test.ts && tsx src/mcp/oauth/oauth-store.test.ts && tsx src/cli/maintenance.test.ts && tsx src/cli/maintenance-prune.test.ts && tsx src/cli/cli.test.ts",
58
59
  "typecheck": "tsc -p tsconfig.json --noEmit",
59
60
  "release:check": "node scripts/release-version.mjs check",
60
61
  "release:tag-check": "node scripts/release-version.mjs tag",
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env node
2
+
3
+ import assert from "node:assert/strict";
4
+ import { existsSync } from "node:fs";
5
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { spawnSync } from "node:child_process";
9
+
10
+ if (process.platform !== "win32") {
11
+ console.log("cmd.exe packaged acceptance skipped outside Windows.");
12
+ process.exit(0);
13
+ }
14
+
15
+ const npmCli = process.env.npm_execpath;
16
+ if (!npmCli) throw new Error("cmd acceptance must run through npm so npm_execpath is available");
17
+
18
+ const root = await mkdtemp(join(tmpdir(), "forgerelay-cmd-acceptance-"));
19
+ try {
20
+ const cmd = resolveCmd();
21
+ const runtime = {
22
+ family: "cmd",
23
+ executable: cmd,
24
+ source: "explicit",
25
+ capabilities: ["cmd-command-language"],
26
+ };
27
+
28
+ await exercisePtyLifecycle(runtime);
29
+ await exercisePackagedCmdShim(cmd);
30
+
31
+ console.log(`cmd.exe acceptance passed with ${cmd}.`);
32
+ } finally {
33
+ await rm(root, { recursive: true, force: true });
34
+ }
35
+
36
+ function resolveCmd() {
37
+ const executable = process.env.ComSpec ?? process.env.COMSPEC;
38
+ if (!executable || !existsSync(executable)) {
39
+ throw new Error(`Windows cmd acceptance requires a valid ComSpec; got ${executable ?? "unset"}.`);
40
+ }
41
+ return executable;
42
+ }
43
+
44
+ async function exercisePtyLifecycle(runtime) {
45
+ const [{ ProcessManager }, { BashOutputStore }] = await Promise.all([
46
+ import("../../dist/mcp/process/process-sessions.js"),
47
+ import("../../dist/activity/history/bash-output-store.js"),
48
+ ]);
49
+ const durableStateDir = join(root, "durable-output-state");
50
+ const outputStore = new BashOutputStore(durableStateDir, {
51
+ outputId: () => "out_cmd_pty_acceptance",
52
+ flushBytes: 1,
53
+ });
54
+ const manager = new ProcessManager({
55
+ commandShellRuntime: runtime,
56
+ outputAudit: outputStore,
57
+ });
58
+
59
+ try {
60
+ const node = quoteCmdArg(process.execPath);
61
+ const ptyScriptDir = join(root, "cmd pty script");
62
+ const ptyScript = join(ptyScriptDir, "interactive lifecycle.cmd");
63
+ await mkdir(ptyScriptDir, { recursive: true });
64
+ await writeFile(
65
+ ptyScript,
66
+ [
67
+ "@echo off",
68
+ "chcp 65001 >nul",
69
+ "echo cmd-pty-ready-雪",
70
+ "set /p FR_CMD_LINE=",
71
+ "echo stdin=%FR_CMD_LINE%",
72
+ `${node} -e "console.log('cols=' + process.stdout.columns + ';rows=' + process.stdout.rows)"`,
73
+ `${node} -e "console.log('cmd-pty-unicode-🙂')"`,
74
+ "exit /b 23",
75
+ "",
76
+ ].join("\r\n"),
77
+ "utf8",
78
+ );
79
+ const pty = await manager.start({
80
+ workspaceId: "cmd-agent",
81
+ workspaceRoot: process.cwd(),
82
+ audit: {
83
+ activityId: "act-cmd-pty",
84
+ turnId: "turn-cmd-pty",
85
+ conversationScopeId: "conversation-cmd-pty",
86
+ },
87
+ cwd: process.cwd(),
88
+ command: quoteCmdArg(ptyScript),
89
+ tty: true,
90
+ columns: 80,
91
+ rows: 24,
92
+ yieldTimeMs: 5,
93
+ });
94
+ assert.equal(pty.running, true);
95
+ assert.ok(pty.processId);
96
+ assert.equal(pty.outputId, "out_cmd_pty_acceptance");
97
+
98
+ const interacted = await manager.write({
99
+ workspaceId: "cmd-agent",
100
+ processId: pty.processId,
101
+ columns: 120,
102
+ rows: 30,
103
+ chars: "input-plain\r",
104
+ yieldTimeMs: 5_000,
105
+ });
106
+ assert.equal(interacted.running, false);
107
+ assert.equal(interacted.exitCode, 23);
108
+ const ptyOutput = `${pty.output}${interacted.output}`;
109
+ assert.match(ptyOutput, /cmd-pty-ready-雪/);
110
+ assert.match(ptyOutput, /stdin=input-plain/);
111
+ assert.match(ptyOutput, /cols=120;rows=30/);
112
+ assert.match(ptyOutput, /cmd-pty-unicode-🙂/);
113
+
114
+ const durable = outputStore.read(pty.outputId);
115
+ assert.ok(durable);
116
+ assert.equal(durable.tty, true);
117
+ assert.equal(durable.exitCode, 23);
118
+ assert.equal(durable.status, "failed");
119
+ assert.match(durable.output, /cmd-pty-ready-雪/);
120
+ assert.match(durable.output, /stdin=input-plain/);
121
+ assert.match(durable.output, /cmd-pty-unicode-🙂/);
122
+
123
+ const background = await manager.start({
124
+ workspaceId: "cmd-agent",
125
+ cwd: process.cwd(),
126
+ command: `${node} -e "console.log('cmd-pty-background-start'); setTimeout(() => console.log('cmd-pty-background-done'), 250)"`,
127
+ tty: true,
128
+ yieldTimeMs: 5,
129
+ });
130
+ assert.equal(background.running, true);
131
+ assert.ok(background.processId);
132
+ const backgroundDone = await manager.write({
133
+ workspaceId: "cmd-agent",
134
+ processId: background.processId,
135
+ yieldTimeMs: 5_000,
136
+ });
137
+ assert.equal(backgroundDone.running, false);
138
+ assert.equal(backgroundDone.exitCode, 0);
139
+ assert.match(`${background.output}${backgroundDone.output}`, /cmd-pty-background-done/);
140
+
141
+ const timedOut = await manager.start({
142
+ workspaceId: "cmd-agent",
143
+ cwd: process.cwd(),
144
+ command: `${node} -e "setTimeout(() => {}, 30000)"`,
145
+ tty: true,
146
+ yieldTimeMs: 5_000,
147
+ timeoutMs: 100,
148
+ });
149
+ assert.equal(timedOut.running, false);
150
+ assert.equal(timedOut.timedOut, true);
151
+
152
+ const pidPath = join(root, "cmd-pty-child.pid");
153
+ const childScript = "require('node:fs').writeFileSync(process.argv[1], String(process.pid)); setInterval(() => {}, 1000)";
154
+ const interruptible = await manager.start({
155
+ workspaceId: "cmd-agent",
156
+ cwd: process.cwd(),
157
+ command: `${node} -e ${quoteCmdArg(childScript)} ${quoteCmdArg(pidPath)}`,
158
+ tty: true,
159
+ yieldTimeMs: 5,
160
+ });
161
+ assert.equal(interruptible.running, true);
162
+ assert.ok(interruptible.processId);
163
+ const childPid = Number.parseInt(await waitForFile(pidPath), 10);
164
+ assert.ok(Number.isInteger(childPid) && childPid > 0, `invalid PTY child pid: ${childPid}`);
165
+ assert.equal(windowsProcessExists(childPid), true);
166
+
167
+ const interrupted = await manager.write({
168
+ workspaceId: "cmd-agent",
169
+ processId: interruptible.processId,
170
+ chars: "\u0003",
171
+ yieldTimeMs: 5_000,
172
+ });
173
+ assert.equal(interrupted.running, false);
174
+ await waitForWindowsProcessExit(childPid);
175
+ assert.equal(windowsProcessExists(childPid), false, `PTY child process ${childPid} leaked after interrupt`);
176
+ } finally {
177
+ manager.shutdown();
178
+ outputStore.close();
179
+ }
180
+ }
181
+
182
+ async function exercisePackagedCmdShim(cmd) {
183
+ const artifactDir = join(root, "artifact");
184
+ const prefix = join(root, "prefix");
185
+ const configDir = join(root, "config");
186
+ const stateDir = join(root, "state");
187
+ await Promise.all([
188
+ mkdir(artifactDir, { recursive: true }),
189
+ mkdir(prefix, { recursive: true }),
190
+ mkdir(configDir, { recursive: true }),
191
+ mkdir(stateDir, { recursive: true }),
192
+ ]);
193
+
194
+ const packed = runNpm(["pack", "--json", "--pack-destination", artifactDir]);
195
+ const packResult = JSON.parse(packed.stdout);
196
+ const filename = packResult?.[0]?.filename;
197
+ if (!filename) throw new Error(`npm pack did not report a package filename: ${packed.stdout}`);
198
+ const tarball = join(artifactDir, filename);
199
+ assert.ok(existsSync(tarball), `packed artifact is missing: ${tarball}`);
200
+
201
+ runNpm(["install", "--global", "--prefix", prefix, tarball]);
202
+ const shim = join(prefix, "forgerelay.cmd");
203
+ assert.ok(existsSync(shim), `npm did not create the cmd launcher shim: ${shim}`);
204
+
205
+ await writeFile(
206
+ join(configDir, "config.json"),
207
+ JSON.stringify({
208
+ host: "127.0.0.1",
209
+ port: 7678,
210
+ allowedRoots: [process.cwd()],
211
+ stateDir,
212
+ worktreeRoot: join(root, "worktrees"),
213
+ commandShell: {
214
+ mode: "follow-launcher",
215
+ family: "cmd",
216
+ executable: cmd,
217
+ },
218
+ shellInstructions: false,
219
+ }, null, 2),
220
+ "utf8",
221
+ );
222
+
223
+ const launcherEnv = {
224
+ ...process.env,
225
+ FORGERELAY_CONFIG_DIR: configDir,
226
+ FORGERELAY_OAUTH_OWNER_TOKEN: "cmd-acceptance-owner-token-that-is-long-enough",
227
+ };
228
+ delete launcherEnv.npm_lifecycle_event;
229
+ delete launcherEnv.FORGERELAY_COMMAND_SHELL;
230
+
231
+ const result = spawnSync(
232
+ cmd,
233
+ ["/d", "/s", "/c", `"\"${shim}\" doctor"`],
234
+ {
235
+ cwd: process.cwd(),
236
+ env: launcherEnv,
237
+ encoding: "utf8",
238
+ windowsHide: true,
239
+ windowsVerbatimArguments: true,
240
+ },
241
+ );
242
+ if (result.error || result.status !== 0) {
243
+ throw new Error(`Packaged cmd launcher failed: ${result.error?.message ?? result.stderr ?? result.status}`);
244
+ }
245
+ assert.match(result.stdout ?? "", /Command shell: cmd \(.+; launcher\)/);
246
+ }
247
+
248
+ function runNpm(args) {
249
+ const result = spawnSync(process.execPath, [npmCli, ...args], {
250
+ cwd: process.cwd(),
251
+ env: process.env,
252
+ encoding: "utf8",
253
+ windowsHide: true,
254
+ });
255
+ if (result.error || result.status !== 0) {
256
+ throw new Error(`npm ${args.join(" ")} failed: ${result.error?.message ?? result.stderr ?? result.status}`);
257
+ }
258
+ return result;
259
+ }
260
+
261
+ async function waitForFile(path, timeoutMs = 5_000) {
262
+ const deadline = Date.now() + timeoutMs;
263
+ while (Date.now() < deadline) {
264
+ try {
265
+ return await readFile(path, "utf8");
266
+ } catch (error) {
267
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
268
+ }
269
+ await new Promise((resolve) => setTimeout(resolve, 25));
270
+ }
271
+ throw new Error(`Timed out waiting for file: ${path}`);
272
+ }
273
+
274
+ function windowsProcessExists(pid) {
275
+ const result = spawnSync(
276
+ "tasklist.exe",
277
+ ["/fi", `PID eq ${pid}`, "/fo", "csv", "/nh"],
278
+ { encoding: "utf8", windowsHide: true },
279
+ );
280
+ if (result.error) throw result.error;
281
+ if (result.status !== 0) throw new Error(`tasklist.exe failed with exit ${result.status ?? "unknown"}`);
282
+ return new RegExp(`"${pid}"(?:,|$)`).test(result.stdout ?? "");
283
+ }
284
+
285
+ async function waitForWindowsProcessExit(pid, timeoutMs = 5_000) {
286
+ const deadline = Date.now() + timeoutMs;
287
+ while (Date.now() < deadline) {
288
+ if (!windowsProcessExists(pid)) return;
289
+ await new Promise((resolve) => setTimeout(resolve, 50));
290
+ }
291
+ }
292
+
293
+ function quoteCmdArg(value) {
294
+ return `"${String(value).replaceAll('"', '""')}"`;
295
+ }