@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 +31 -0
- package/README.md +34 -14
- package/capabilities/code-intelligence/GUIDE.md +3 -1
- package/dist/hooks.js +43 -14
- package/dist/lsp/test-support/server-fixture.js +6 -1
- package/dist/mcp/server-instructions.js +1 -1
- package/dist/process-sessions.js +119 -14
- package/dist/server.js +165 -103
- package/docs/chatgpt-coding-workflow.md +10 -5
- package/docs/configuration.md +34 -17
- package/docs/roadmap.md +11 -1
- package/docs/versioning.md +20 -5
- package/examples/language-servers.json +30 -0
- package/package.json +5 -3
- package/scripts/debug/accept.mjs +28 -3
- package/scripts/debug/code-intelligence-accept.mjs +169 -0
- package/scripts/lsp-interop-support.mjs +49 -0
- package/scripts/lsp-interop-support.test.mjs +40 -0
- package/scripts/lsp-interop.mjs +180 -0
- package/scripts/release-parity.mjs +85 -0
- package/scripts/release-proof.mjs +139 -0
- package/scripts/release-proof.test.mjs +104 -0
- package/scripts/release-version.mjs +1 -1
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": "
|
|
151
|
-
"timeoutSeconds":
|
|
166
|
+
"command": "node scripts/release-proof.mjs check-hook",
|
|
167
|
+
"timeoutSeconds": 30,
|
|
152
168
|
"report": true
|
|
153
169
|
}
|
|
154
170
|
```
|
|
155
171
|
|
|
156
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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.
|
|
254
|
-
2.
|
|
255
|
-
3.
|
|
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,
|
|
280
|
-
local release verification
|
|
281
|
-
`
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
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
|
-
|
|
141
|
-
...
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
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
|
}
|
package/dist/process-sessions.js
CHANGED
|
@@ -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
|
-
|
|
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()]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
530
|
+
outputTruncated: processEntry.outputWasTruncated,
|
|
458
531
|
running: processEntry.running,
|
|
459
532
|
exitCode: processEntry.exitCode,
|
|
460
533
|
signal: processEntry.signal,
|
|
461
|
-
|
|
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
|
|
476
|
-
|
|
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)
|