@akira-tl/forgerelay 0.4.5 → 0.4.7

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,37 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.4.7] - 2026-08-11
8
+
9
+ ### Added
10
+
11
+ - Added independent `bash` execution deadlines with optional `timeoutMs`; `yieldTimeMs` now remains purely a feedback window, including `yieldTimeMs: 0` for immediate background handoff to a canonical `processId`.
12
+
13
+ ### Changed
14
+
15
+ - Regular `bash` now defaults to a 10-second feedback window instead of occupying a full 300-second Host request; Agent-selected waits can still be up to 300 seconds, while execution can continue without a ForgeRelay deadline when `timeoutMs` is omitted.
16
+ - Completed background processes keep full buffered output for five minutes, then compact to a bounded completion record deliverable for up to 24 hours. Completed results no longer block workspace close and are delivered with the close response when available.
17
+ - Local `release:verify` now records a proof for the committed release HEAD, while the stable-tag `BeforeTool` Hook performs only a fast proof/HEAD/version/tag check before the push instead of rerunning the multi-minute release gate inside one MCP request.
18
+
19
+ ### Fixed
20
+
21
+ - Propagated Host request cancellation through lifecycle Hooks and shell process waits. If a Host cancels while a blocking `BeforeTool` Hook is still running, ForgeRelay terminates the Hook and does not execute the original tool side effect; cancellation of an initial shell run also terminates a process whose `processId` has not yet been delivered.
22
+ - Corrected `ProcessManager` yield bounding so a configured maximum also caps the default feedback window rather than only explicit `yieldTimeMs` values.
23
+ - Made the new release-proof and Hook-cancellation test harnesses use cross-platform Node path and shell invocation forms, covering Windows drive-letter paths and `cmd.exe` Hook execution as well as POSIX hosts.
24
+
25
+ ## [0.4.6] - 2026-08-11
26
+
27
+ ### Added
28
+
29
+ - Added published `npm run lsp:interop` acceptance for `typescript-language-server`, Pyright, `rust-analyzer`, `gopls`, and `clangd`: detected executables run through built-in discovery and real stdio LSP, while missing external servers are reported as explicit skips without automatic installation.
30
+ - Extended the real 7677 HTTP/OAuth/MCP acceptance path to execute all six `code.intelligence` v1 operations, normalized results, a stable error path, and Language-service `shutdown -> exit` behavior while keeping exactly nine Core MCP tools.
31
+
32
+ ### Changed
33
+
34
+ - Cloud release CI now runs optional real-server interoperability after the normal deterministic fake-LSP test/build gate on Linux, macOS, and Windows. Executable preflight now distinguishes a runnable Language server from PATH shims/proxies that exist but fail to launch, so incomplete rustup components are skipped rather than misreported as installed servers.
35
+ - Local `release:verify` now includes an isolated Node 22.19.0 parity sandbox with its own `npm ci` and focused LSP/release regression suite; cloud CI and publication use the same Node 22.19.0 runtime to reduce local/cloud drift, while Windows test cleanup retries transient locked-directory removal.
36
+ - Completed the 0.4 LSP v1 user, configuration, roadmap, contributor, and explicit Language-server example documentation, and hardened cross-platform/concurrency acceptance timing exposed during final release validation.
37
+
7
38
  ## [0.4.5] - 2026-08-11
8
39
 
9
40
  ### Added
package/README.md CHANGED
@@ -112,6 +112,22 @@ reports a semantic capability but the Host still shows an older tool snapshot,
112
112
  refresh or reconnect the integration rather than assuming the capability is
113
113
  missing from ForgeRelay.
114
114
 
115
+ ## LSP code intelligence
116
+
117
+ ForgeRelay 0.4 LSP v1 exposes semantic code navigation through the
118
+ `code.intelligence` Capability without adding language-specific top-level MCP tools.
119
+ The v1 operations are definition, hover/type information, references, document
120
+ symbols, workspace symbols, and diagnostics. Results use ForgeRelay-owned normalized
121
+ locations, ranges, symbols, hover content, and diagnostic shapes rather than raw LSP
122
+ wire unions.
123
+
124
+ Language servers remain external dependencies. ForgeRelay can discover
125
+ `typescript-language-server`, `pyright-langserver`, `rust-analyzer`, `gopls`, and
126
+ `clangd` when they already exist on `PATH`, or use structured project/global
127
+ configuration, but it never installs a server automatically. See
128
+ [Configuration Reference](docs/configuration.md#lsp-code-intelligence) and
129
+ [`examples/language-servers.json`](examples/language-servers.json).
130
+
115
131
  ## Worktrees without the usual cleanup mess
116
132
 
117
133
  A new managed worktree gets its own `forgerelay/*` branch instead of a detached
@@ -147,13 +163,13 @@ Hook 是 ForgeRelay 的自动生命周期规则。首选方式是一个 Hook 一
147
163
  "tool": "bash",
148
164
  "commandRegex": "git\\s+push\\s+origin\\s+v\\d+\\.\\d+\\.\\d+"
149
165
  },
150
- "command": "npm run release:verify",
151
- "timeoutSeconds": 300,
166
+ "command": "node scripts/release-proof.mjs check-hook",
167
+ "timeoutSeconds": 30,
152
168
  "report": true
153
169
  }
154
170
  ```
155
171
 
156
- 命中 `BeforeTool` 后,Hook 先执行;成功才继续原始 `git push`,失败则直接阻断。Hook 结果会回到 Agent,Agent 应向用户说明重要 Hook 是否通过或阻断了操作。`report:false` 可以隐藏不重要的成功报告,但阻断失败始终可见。
172
+ 耗时的 `npm run release:verify` 应先在已提交的 release-ready HEAD 上运行,它会写入绑定 HEAD/package version 的本地 release proof。命中 `BeforeTool` 后,Hook 只快速验证该 proof、clean working tree(含 untracked)与 tag 指向;成功才继续原始 `git push`,失败则直接阻断。这样发布 gate 不依赖一个持续数分钟的 MCP 请求。Hook 结果会回到 Agent,Agent 应向用户说明重要 Hook 是否通过或阻断了操作。`report:false` 可以隐藏不重要的成功报告,但阻断失败始终可见。
157
173
 
158
174
  旧的 inline `hooks` 和聚合 `hooks.json` 仍兼容;新配置建议都用独立 `hooks/*.json` 文件。
159
175
 
@@ -247,13 +263,13 @@ forgerelay doctor
247
263
 
248
264
  ## Where ForgeRelay is going
249
265
 
250
- The next additions are focused on making the local execution layer more useful,
251
- not on turning ForgeRelay into another all-in-one agent framework:
266
+ With LSP code intelligence established in the 0.4 line, the next additions remain
267
+ focused on making the local execution layer more useful, not on turning ForgeRelay
268
+ into another all-in-one agent framework:
252
269
 
253
- 1. LSP-backed code intelligence;
254
- 2. first-class MCP subagent delegation;
255
- 3. stronger worktree verification and recovery;
256
- 4. checkpoint/rewind and retention improvements.
270
+ 1. first-class MCP subagent delegation;
271
+ 2. stronger worktree verification and recovery;
272
+ 3. checkpoint/rewind and retention improvements.
257
273
 
258
274
  ForgeRelay does not plan to add its own shell sandbox, long-term memory system,
259
275
  or plugin marketplace. Conversation, planning, web access, and other host-native
@@ -276,11 +292,15 @@ npm run release:major
276
292
  npm run release:verify
277
293
  ```
278
294
 
279
- Daily branch pushes do not run cloud CI. When preparing a release, run the full
280
- local release verification first. Pushing a matching `vX.Y.Z` tag to
281
- `Akira-TL/forgerelay` is the only cloud CI and publish trigger: GitHub Actions
282
- runs the reusable multi-platform CI, then publishes `@akira-tl/forgerelay` and
283
- creates the matching GitHub Release only after CI succeeds.
295
+ Daily branch pushes do not run cloud CI. When preparing a release, commit the
296
+ release-ready tree and run the full local release verification on that clean HEAD.
297
+ `release:verify` includes a focused parity pass in an isolated Node 22.19.0 environment
298
+ with its own `npm ci`, matching the cloud CI runtime for native addons and high-risk
299
+ LSP lifecycle tests, then writes the local proof consumed by the tag-push Hook. Pushing a matching
300
+ `vX.Y.Z` tag to `Akira-TL/forgerelay` is the only cloud CI and publish trigger:
301
+ GitHub Actions runs the reusable multi-platform CI, then publishes
302
+ `@akira-tl/forgerelay` and creates the matching GitHub Release only after CI
303
+ succeeds.
284
304
 
285
305
  See [Versioning and Release Management](docs/versioning.md) for the bootstrap and
286
306
  Trusted Publishing setup.
@@ -4,7 +4,7 @@ Use the `code.intelligence` Capability for read-only semantic code navigation ba
4
4
 
5
5
  ForgeRelay does not install Language servers. It discovers supported executables when available and accepts explicit definitions from the global ForgeRelay config or `<workspace>/.forgerelay/language-servers.json`. Project definitions override global definitions, and global definitions override built-in discovery. An explicit definition may disable discovery with `enabled: false`.
6
6
 
7
- ForgeRelay 0.4.4 adds `diagnostics` alongside `definition`, `hover`, `references`, `documentSymbols`, and `workspaceSymbols`. Position-based operations accept a workspace-relative source `path` plus 1-based `line` and `column` values. `documentSymbols` needs only `path` and an optional bounded `limit`. `workspaceSymbols` uses `path` to select the Language project/service and accepts a `query` plus optional `limit`; it does not merge multiple nested Language services. `diagnostics` uses `path` plus an optional bounded `limit`. Columns are Unicode code-point positions; ForgeRelay converts them to the position encoding negotiated with the Language server.
7
+ ForgeRelay 0.4 LSP v1 exposes `definition`, `hover`, `references`, `documentSymbols`, `workspaceSymbols`, and `diagnostics`. Position-based operations accept a workspace-relative source `path` plus 1-based `line` and `column` values. `documentSymbols` needs only `path` and an optional bounded `limit`. `workspaceSymbols` uses `path` to select the Language project/service and accepts a `query` plus optional `limit`; it does not merge multiple nested Language services. `diagnostics` uses `path` plus an optional bounded `limit`. Columns are Unicode code-point positions; ForgeRelay converts them to the position encoding negotiated with the Language server.
8
8
 
9
9
  Code-intelligence results use ForgeRelay-owned shapes rather than raw LSP wire types. Definition returns normalized locations. Hover returns one `contents` string, an optional legacy `language`, and an optional ForgeRelay-normalized `range`; plaintext, Markdown `MarkupContent`, and supported legacy `MarkedString` payloads are normalized before reaching the Agent. References uses the same normalized location shape, defaults to `limit: 100`, and accepts limits up to 1000. Its result reports `returned`, `truncated`, and `total` when the complete Language-server response makes the total known.
10
10
 
@@ -21,3 +21,5 @@ Semantic Language-server requests use an internal bounded deadline; the Agent co
21
21
  An unexpected Language-server process exit fails its pending JSON-RPC work immediately instead of waiting for the semantic deadline. ForgeRelay discards that service and retries the current semantic operation at most once. A second consecutive crash for the same Language project/server-definition fingerprint enters a short cooldown before another process may be started; a successful retry clears the crash state. Effective server definitions are fingerprinted, so a changed project/global/built-in definition selects a new service identity on the next code-intelligence resolution and retires only an idle service with the same project root/server id but the old fingerprint. ForgeRelay does not add a recursive config watcher.
22
22
 
23
23
  Language services are shared by physical Language project identity rather than logical Workspace id, so multiple conversations over the same checkout do not multiply server processes. Idle services are reclaimed after a bounded TTL, and the global service cap evicts the least-recently-used truly idle service; a request that has returned to the Host but whose server ignored cancellation still counts as active until the underlying LSP request settles. Managed-worktree finalization releases idle Language services rooted in that worktree before removing it and refuses finalization while semantic work is still active. Debug runtime telemetry reports only aggregate service/process/request/document/diagnostic/stderr counts and never source contents.
24
+
25
+ The built-in discovery candidates are `typescript-language-server`, `pyright-langserver`, `rust-analyzer`, `gopls`, and `clangd`. Contributors can run `npm run lsp:interop` to exercise any of those executables that are already present on `PATH`; each detected server is tested through built-in discovery and real stdio LSP, while absent servers are reported as explicit skips. The interoperability check never downloads or installs a Language server.
package/dist/hooks.js CHANGED
@@ -105,7 +105,8 @@ export async function runToolWithHooks(runner, options) {
105
105
  executions.push(...await runner.run("BeforeTool", {
106
106
  ...options.invocation,
107
107
  payload: basePayload,
108
- }));
108
+ }, options.signal));
109
+ options.signal?.throwIfAborted();
109
110
  const result = await options.operation();
110
111
  const afterCwd = options.afterCwd?.(result);
111
112
  if (options.isFailure?.(result)) {
@@ -113,7 +114,7 @@ export async function runToolWithHooks(runner, options) {
113
114
  ...options.invocation,
114
115
  cwd: afterCwd,
115
116
  payload: basePayload,
116
- }));
117
+ }, options.signal));
117
118
  const reported = attachHookReports(result, executions);
118
119
  return decorateToolResult(runner, options.invocation.workspaceId, reported);
119
120
  }
@@ -121,14 +122,14 @@ export async function runToolWithHooks(runner, options) {
121
122
  ...options.invocation,
122
123
  cwd: afterCwd,
123
124
  payload: basePayload,
124
- }));
125
+ }, options.signal));
125
126
  const changedPaths = options.changedPaths?.(result) ?? [];
126
127
  if (changedPaths.length > 0) {
127
128
  executions.push(...await runner.run("AfterFileChange", {
128
129
  ...options.invocation,
129
130
  cwd: afterCwd,
130
131
  payload: { ...basePayload, paths: changedPaths },
131
- }));
132
+ }, options.signal));
132
133
  }
133
134
  const reported = attachHookReports(result, executions);
134
135
  return decorateToolResult(runner, options.invocation.workspaceId, reported);
@@ -137,13 +138,15 @@ export async function runToolWithHooks(runner, options) {
137
138
  if (error instanceof HookExecutionError) {
138
139
  executions.push(...error.executions);
139
140
  }
140
- executions.push(...await runner.run("AfterToolFailure", {
141
- ...options.invocation,
142
- payload: {
143
- ...basePayload,
144
- errorType: error instanceof Error ? error.name : "Error",
145
- },
146
- }));
141
+ if (!options.signal?.aborted) {
142
+ executions.push(...await runner.run("AfterToolFailure", {
143
+ ...options.invocation,
144
+ payload: {
145
+ ...basePayload,
146
+ errorType: error instanceof Error ? error.name : "Error",
147
+ },
148
+ }, options.signal));
149
+ }
147
150
  const reportedError = appendHookReportsToError(error, executions);
148
151
  throw decorateToolResult(runner, options.invocation.workspaceId, reportedError);
149
152
  }
@@ -219,7 +222,8 @@ export class HookRunner {
219
222
  decorateResult(workspaceId, result) {
220
223
  return (this.resultDecorator?.(workspaceId, result) ?? result);
221
224
  }
222
- async run(event, invocation) {
225
+ async run(event, invocation, signal) {
226
+ signal?.throwIfAborted();
223
227
  const projectRoot = event === "AfterWorktreeClose" && invocation.sourceRoot
224
228
  ? invocation.sourceRoot
225
229
  : invocation.workspaceRoot;
@@ -246,7 +250,8 @@ export class HookRunner {
246
250
  }]
247
251
  : [];
248
252
  for (const [index, { scope, handler, invocation: matchedInvocation }] of handlers.entries()) {
249
- const execution = await this.runHandler(event, handler, index, matchedInvocation, scope);
253
+ signal?.throwIfAborted();
254
+ const execution = await this.runHandler(event, handler, index, matchedInvocation, scope, signal);
250
255
  executions.push(execution);
251
256
  logEvent(this.logging, execution.status === "passed" ? "info" : "warn", "hook_call", {
252
257
  hookEvent: event,
@@ -267,7 +272,7 @@ export class HookRunner {
267
272
  }
268
273
  return executions;
269
274
  }
270
- async runHandler(event, handler, index, invocation, scope) {
275
+ async runHandler(event, handler, index, invocation, scope, signal) {
271
276
  const startedAt = performance.now();
272
277
  const name = handler.name ?? `${event} handler ${index + 1}`;
273
278
  const shell = resolveShellCommand(handler.command, process.platform, this.baseEnv);
@@ -282,6 +287,7 @@ export class HookRunner {
282
287
  env,
283
288
  timeoutMs: handler.timeoutSeconds * 1_000,
284
289
  detached,
290
+ signal,
285
291
  });
286
292
  const durationMs = Math.round(performance.now() - startedAt);
287
293
  if (result.exitCode === 0 && !result.timedOut) {
@@ -311,6 +317,8 @@ export class HookRunner {
311
317
  };
312
318
  }
313
319
  catch (error) {
320
+ if (signal?.aborted)
321
+ throw error;
314
322
  return {
315
323
  event,
316
324
  name,
@@ -522,6 +530,7 @@ function hookEnvironment(baseEnv, event, invocation) {
522
530
  };
523
531
  }
524
532
  function executeHookCommand(input) {
533
+ input.signal?.throwIfAborted();
525
534
  return new Promise((resolve, reject) => {
526
535
  const child = spawn(input.executable, input.args, {
527
536
  cwd: input.cwd,
@@ -535,6 +544,18 @@ function executeHookCommand(input) {
535
544
  let stderr = "";
536
545
  let timedOut = false;
537
546
  let forceKillTimer;
547
+ let aborted = false;
548
+ const abort = () => {
549
+ if (aborted)
550
+ return;
551
+ aborted = true;
552
+ terminateProcessTree(child, "SIGTERM", input.detached);
553
+ forceKillTimer = setTimeout(() => {
554
+ terminateProcessTree(child, "SIGKILL", input.detached);
555
+ }, 500);
556
+ forceKillTimer.unref();
557
+ };
558
+ input.signal?.addEventListener("abort", abort, { once: true });
538
559
  child.stdout?.on("data", (chunk) => {
539
560
  stdout = appendCaptured(stdout, chunk);
540
561
  });
@@ -554,12 +575,20 @@ function executeHookCommand(input) {
554
575
  clearTimeout(timeout);
555
576
  if (forceKillTimer)
556
577
  clearTimeout(forceKillTimer);
578
+ input.signal?.removeEventListener("abort", abort);
557
579
  reject(error);
558
580
  });
559
581
  child.once("close", (exitCode, signal) => {
560
582
  clearTimeout(timeout);
561
583
  if (forceKillTimer)
562
584
  clearTimeout(forceKillTimer);
585
+ input.signal?.removeEventListener("abort", abort);
586
+ if (aborted) {
587
+ reject(input.signal?.reason instanceof Error
588
+ ? input.signal.reason
589
+ : Object.assign(new Error("Hook execution cancelled by Host."), { name: "AbortError" }));
590
+ return;
591
+ }
563
592
  resolve({ exitCode, signal, stdout, stderr, timedOut });
564
593
  });
565
594
  });
@@ -51,7 +51,12 @@ export async function createCodeIntelligenceServerFixture(t, options = {}) {
51
51
  };
52
52
  t.after(async () => {
53
53
  await close();
54
- await rm(root, { recursive: true, force: true });
54
+ await rm(root, {
55
+ recursive: true,
56
+ force: true,
57
+ maxRetries: 8,
58
+ retryDelay: 100,
59
+ });
55
60
  });
56
61
  return { client, project, codeIntelligence, close };
57
62
  }
@@ -30,7 +30,7 @@ export function buildToolDescriptions(config) {
30
30
  rename: `Rename or move one file or directory inside an open workspace or the OS temp directory without overwriting an existing destination. Source and destination must both remain inside the permitted file roots. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
31
31
  delete: `Delete one file or directory inside an open workspace or the OS temp directory. Non-empty directories require recursive=true. An allowed root itself cannot be deleted. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
32
32
  applyPatch: `Apply one Codex-style patch inside an open workspace or the OS temp directory. Supports adding, overwriting, updating, deleting, and moving files. Workspace paths must remain relative; absolute paths are accepted only inside the OS temp directory. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
33
- shell: `Run or manage a shell process inside an open workspace.${shellSurface} Commands execute with the local user's authority; workspace filesystem containment does not make shell execution a sandbox. action=run (default) starts a command and waits up to 300 seconds; action=process uses its processId to poll, wait, write input, resize a PTY, or interrupt it. Completed background commands may also be reported later for the same workspaceId. Call ${toolNames.openWorkspace} first and pass workspaceId. Expose this capability only behind strong authentication.`,
33
+ shell: `Run or manage a shell process inside an open workspace.${shellSurface} Commands execute with the local user's authority; workspace containment does not make shell execution a sandbox. For action=run, yieldTimeMs is only the feedback wait (default 10000ms; 0 returns a processId immediately) and optional timeoutMs is the independent total execution limit. action=process polls/waits for incremental output, writes input, resizes a PTY, or interrupts by processId. Keep explicit waits below the Host request deadline; use 60000ms only when supported. Completed background results may be attached to a later result for the same workspaceId. Call ${toolNames.openWorkspace} first and pass workspaceId. Expose this capability only behind strong authentication.`,
34
34
  shellCommand: "Shell command to run with the local user's authority.",
35
35
  };
36
36
  }
@@ -6,21 +6,33 @@ const DEFAULT_POLL_YIELD_MS = 5_000;
6
6
  const MAX_START_YIELD_MS = 300_000;
7
7
  const MAX_COMMAND_YIELD_MS = 300_000;
8
8
  const MAX_POLL_YIELD_MS = 300_000;
9
+ const MAX_EXECUTION_TIMEOUT_MS = 24 * 60 * 60 * 1_000;
9
10
  const DEFAULT_MAX_OUTPUT_TOKENS = 10_000;
10
11
  const DEFAULT_BUFFER_CHARACTERS = 256_000;
11
12
  const DEFAULT_MAX_ACTIVE_PROCESSES = 64;
12
13
  const DEFAULT_MAX_COMPLETED_PROCESSES = 128;
13
14
  const COMPLETED_PROCESS_TTL_MS = 5 * 60 * 1_000;
15
+ const COMPACT_COMPLETION_TTL_MS = 24 * 60 * 60 * 1_000;
16
+ const COMPACT_COMPLETION_CHARACTERS = 16 * 1024;
17
+ const COMPACT_COMPLETION_OUTPUT_TOKENS = COMPACT_COMPLETION_CHARACTERS / 4;
14
18
  const DEFAULT_COLUMNS = 80;
15
19
  const DEFAULT_ROWS = 24;
16
20
  function boundedInteger(value, fallback, maximum) {
17
21
  if (value === undefined)
18
- return fallback;
22
+ return Math.min(fallback, maximum);
19
23
  if (!Number.isFinite(value) || value < 0) {
20
24
  throw new Error("Duration and output limits must be non-negative.");
21
25
  }
22
26
  return Math.min(Math.floor(value), maximum);
23
27
  }
28
+ function optionalExecutionTimeout(value) {
29
+ if (value === undefined)
30
+ return undefined;
31
+ if (!Number.isInteger(value) || value < 1 || value > MAX_EXECUTION_TIMEOUT_MS) {
32
+ throw new Error(`Execution timeout must be an integer between 1 and ${MAX_EXECUTION_TIMEOUT_MS}ms.`);
33
+ }
34
+ return value;
35
+ }
24
36
  function terminalSize(value, fallback) {
25
37
  if (value === undefined)
26
38
  return fallback;
@@ -215,9 +227,11 @@ export class ProcessManager {
215
227
  this.monotonicNow = options.monotonicNow ?? (() => performance.now());
216
228
  }
217
229
  async start(input) {
230
+ input.signal?.throwIfAborted();
218
231
  if (this.stats().running >= this.maxActiveProcesses) {
219
232
  throw new Error(`Active process limit reached (${this.maxActiveProcesses}). Poll, interrupt, or wait for an existing process before starting another.`);
220
233
  }
234
+ const executionTimeoutMs = optionalExecutionTimeout(input.timeoutMs);
221
235
  const processEntry = this.createProcess(input);
222
236
  this.processes.set(processEntry.id, processEntry);
223
237
  try {
@@ -230,8 +244,19 @@ export class ProcessManager {
230
244
  this.processes.delete(processEntry.id);
231
245
  throw error;
232
246
  }
247
+ this.armExecutionTimeout(processEntry, executionTimeoutMs);
233
248
  const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_EXEC_YIELD_MS, this.maxStartYieldMs);
234
- await this.waitForExit(processEntry, yieldTimeMs);
249
+ try {
250
+ await this.waitForExit(processEntry, yieldTimeMs, input.signal);
251
+ input.signal?.throwIfAborted();
252
+ }
253
+ catch (error) {
254
+ if (input.signal?.aborted) {
255
+ processEntry.discardOnFinish = true;
256
+ this.stopProcess(processEntry, "SIGTERM");
257
+ }
258
+ throw error;
259
+ }
235
260
  if (processEntry.running)
236
261
  processEntry.background = true;
237
262
  const snapshot = this.consume(processEntry, input.maxOutputTokens);
@@ -263,7 +288,7 @@ export class ProcessManager {
263
288
  const fallback = interactionRequested ? DEFAULT_INTERACTIVE_YIELD_MS : DEFAULT_POLL_YIELD_MS;
264
289
  const maximum = interactionRequested ? MAX_COMMAND_YIELD_MS : MAX_POLL_YIELD_MS;
265
290
  const yieldTimeMs = boundedInteger(input.yieldTimeMs, fallback, maximum);
266
- await this.waitForExit(processEntry, yieldTimeMs);
291
+ await this.waitForExit(processEntry, yieldTimeMs, input.signal);
267
292
  }
268
293
  const snapshot = this.consume(processEntry, input.maxOutputTokens);
269
294
  if (!processEntry.running)
@@ -271,7 +296,9 @@ export class ProcessManager {
271
296
  return snapshot;
272
297
  }
273
298
  activeWorkspaceIds() {
274
- return new Set([...this.processes.values()].map((processEntry) => processEntry.workspaceId));
299
+ return new Set([...this.processes.values()]
300
+ .filter((processEntry) => processEntry.running)
301
+ .map((processEntry) => processEntry.workspaceId));
275
302
  }
276
303
  stats() {
277
304
  let running = 0;
@@ -311,10 +338,17 @@ export class ProcessManager {
311
338
  if (processEntry.running)
312
339
  processEntry.process?.kill("SIGTERM");
313
340
  }
341
+ discardUndelivered(workspaceId, processId) {
342
+ const processEntry = this.getOwnedProcess(workspaceId, processId);
343
+ processEntry.discardOnFinish = true;
344
+ if (processEntry.running)
345
+ this.stopProcess(processEntry, "SIGTERM");
346
+ else
347
+ this.removeProcess(processEntry.id);
348
+ }
314
349
  shutdown() {
315
350
  for (const processEntry of this.processes.values()) {
316
- if (processEntry.cleanupTimer)
317
- clearTimeout(processEntry.cleanupTimer);
351
+ this.clearProcessTimers(processEntry);
318
352
  if (processEntry.running)
319
353
  processEntry.process?.kill("SIGTERM");
320
354
  }
@@ -322,19 +356,32 @@ export class ProcessManager {
322
356
  this.completedByWorkspace.clear();
323
357
  this.completedProcessIds.length = 0;
324
358
  }
325
- async waitForExit(processEntry, yieldTimeMs) {
359
+ async waitForExit(processEntry, yieldTimeMs, signal) {
360
+ signal?.throwIfAborted();
326
361
  let timer;
362
+ let abortListener;
327
363
  try {
328
- await Promise.race([
364
+ const waits = [
329
365
  processEntry.exitPromise,
330
366
  new Promise((resolve) => {
331
367
  timer = setTimeout(resolve, yieldTimeMs);
332
368
  }),
333
- ]);
369
+ ];
370
+ if (signal) {
371
+ waits.push(new Promise((_resolve, reject) => {
372
+ abortListener = () => reject(signal.reason instanceof Error
373
+ ? signal.reason
374
+ : Object.assign(new Error("Process wait cancelled by Host."), { name: "AbortError" }));
375
+ signal.addEventListener("abort", abortListener, { once: true });
376
+ }));
377
+ }
378
+ await Promise.race(waits);
334
379
  }
335
380
  finally {
336
381
  if (timer)
337
382
  clearTimeout(timer);
383
+ if (signal && abortListener)
384
+ signal.removeEventListener("abort", abortListener);
338
385
  }
339
386
  }
340
387
  createProcess(input) {
@@ -351,7 +398,10 @@ export class ProcessManager {
351
398
  rows: terminalSize(input.rows, DEFAULT_ROWS),
352
399
  buffer: new HeadTailBuffer(this.maxBufferCharacters),
353
400
  running: true,
401
+ timedOut: false,
402
+ outputWasTruncated: false,
354
403
  background: false,
404
+ discardOnFinish: false,
355
405
  exitPromise,
356
406
  resolveExit,
357
407
  };
@@ -423,9 +473,18 @@ export class ProcessManager {
423
473
  processEntry.running = false;
424
474
  processEntry.exitCode = exitCode;
425
475
  processEntry.signal = signal;
476
+ processEntry.finishedAtMonotonic = this.monotonicNow();
426
477
  processEntry.process = undefined;
478
+ if (processEntry.executionTimeoutTimer)
479
+ clearTimeout(processEntry.executionTimeoutTimer);
480
+ if (processEntry.forceKillTimer)
481
+ clearTimeout(processEntry.forceKillTimer);
427
482
  processEntry.resolveExit();
428
- processEntry.cleanupTimer = setTimeout(() => this.removeProcess(processEntry.id), this.completedProcessTtlMs);
483
+ if (processEntry.discardOnFinish) {
484
+ this.removeProcess(processEntry.id);
485
+ return;
486
+ }
487
+ processEntry.cleanupTimer = setTimeout(() => this.compactCompletedProcess(processEntry), this.completedProcessTtlMs);
429
488
  processEntry.cleanupTimer.unref();
430
489
  if (processEntry.background) {
431
490
  const completed = this.completedByWorkspace.get(processEntry.workspaceId) ?? [];
@@ -442,6 +501,17 @@ export class ProcessManager {
442
501
  }
443
502
  }
444
503
  }
504
+ compactCompletedProcess(processEntry) {
505
+ if (processEntry.running || !this.processes.has(processEntry.id))
506
+ return;
507
+ const compacted = this.consume(processEntry, COMPACT_COMPLETION_OUTPUT_TOKENS);
508
+ processEntry.buffer = new HeadTailBuffer(COMPACT_COMPLETION_CHARACTERS);
509
+ processEntry.buffer.append(compacted.output);
510
+ processEntry.outputWasTruncated = compacted.outputTruncated;
511
+ const remainingMs = Math.max(1, COMPACT_COMPLETION_TTL_MS - this.completedProcessTtlMs);
512
+ processEntry.cleanupTimer = setTimeout(() => this.removeProcess(processEntry.id), remainingMs);
513
+ processEntry.cleanupTimer.unref();
514
+ }
445
515
  append(processEntry, output) {
446
516
  processEntry.buffer.append(output);
447
517
  }
@@ -449,18 +519,53 @@ export class ProcessManager {
449
519
  const limit = boundedInteger(maxOutputTokens, DEFAULT_MAX_OUTPUT_TOKENS, 100_000);
450
520
  const maxCharacters = Math.max(256, limit * 4);
451
521
  const buffered = processEntry.buffer.drain(maxCharacters);
522
+ if (buffered.truncated)
523
+ processEntry.outputWasTruncated = true;
452
524
  const processId = processEntry.running ? processEntry.id : undefined;
525
+ const endedAt = processEntry.finishedAtMonotonic ?? this.monotonicNow();
453
526
  return {
454
527
  processId,
455
528
  sessionId: processId,
456
529
  output: buffered.output,
457
- outputTruncated: buffered.truncated,
530
+ outputTruncated: processEntry.outputWasTruncated,
458
531
  running: processEntry.running,
459
532
  exitCode: processEntry.exitCode,
460
533
  signal: processEntry.signal,
461
- wallTimeMs: Math.max(0, Math.round(this.monotonicNow() - processEntry.startedAtMonotonic)),
534
+ timedOut: processEntry.timedOut,
535
+ wallTimeMs: Math.max(0, Math.round(endedAt - processEntry.startedAtMonotonic)),
462
536
  };
463
537
  }
538
+ armExecutionTimeout(processEntry, timeoutMs) {
539
+ if (timeoutMs === undefined)
540
+ return;
541
+ processEntry.executionTimeoutTimer = setTimeout(() => {
542
+ if (!processEntry.running)
543
+ return;
544
+ processEntry.timedOut = true;
545
+ this.stopProcess(processEntry, "SIGTERM");
546
+ }, timeoutMs);
547
+ processEntry.executionTimeoutTimer.unref();
548
+ }
549
+ stopProcess(processEntry, signal) {
550
+ if (!processEntry.running)
551
+ return;
552
+ processEntry.process?.kill(signal);
553
+ if (processEntry.forceKillTimer)
554
+ clearTimeout(processEntry.forceKillTimer);
555
+ processEntry.forceKillTimer = setTimeout(() => {
556
+ if (processEntry.running)
557
+ processEntry.process?.kill("SIGKILL");
558
+ }, 500);
559
+ processEntry.forceKillTimer.unref();
560
+ }
561
+ clearProcessTimers(processEntry) {
562
+ if (processEntry.cleanupTimer)
563
+ clearTimeout(processEntry.cleanupTimer);
564
+ if (processEntry.executionTimeoutTimer)
565
+ clearTimeout(processEntry.executionTimeoutTimer);
566
+ if (processEntry.forceKillTimer)
567
+ clearTimeout(processEntry.forceKillTimer);
568
+ }
464
569
  getOwnedProcess(workspaceId, processId) {
465
570
  const processEntry = this.processes.get(processId);
466
571
  if (!processEntry)
@@ -472,8 +577,8 @@ export class ProcessManager {
472
577
  }
473
578
  removeProcess(processId) {
474
579
  const processEntry = this.processes.get(processId);
475
- if (processEntry?.cleanupTimer)
476
- clearTimeout(processEntry.cleanupTimer);
580
+ if (processEntry)
581
+ this.clearProcessTimers(processEntry);
477
582
  this.processes.delete(processId);
478
583
  const completedIndex = this.completedProcessIds.indexOf(processId);
479
584
  if (completedIndex >= 0)