@akira-tl/forgerelay 0.2.2 → 0.2.4
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 +25 -0
- package/dist/hooks.js +18 -4
- package/dist/mcp/server-instructions.js +16 -9
- package/dist/pi-tools.js +1 -9
- package/dist/process-sessions.js +56 -8
- package/dist/server.js +218 -137
- package/dist/workspace-store.js +13 -0
- package/dist/workspaces.js +275 -25
- package/docs/chatgpt-coding-workflow.md +26 -1
- package/docs/configuration.md +20 -2
- package/docs/debugging.md +3 -3
- package/docs/security.md +25 -0
- package/package.json +1 -1
- package/scripts/debug/accept.mjs +21 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,31 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.2.4] - 2026-08-09
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Conversation-scoped logical workspace handles: the same conversation keeps a stable `workspaceId`, different conversations normally receive separate IDs for the same physical checkout/worktree, `open_workspace` can explicitly resume a known ID or allocate a user-requested fresh logical handle, and workspaces idle for more than two days are reported for user-directed resumption or cleanup.
|
|
12
|
+
- `close_workspace` releases a logical workspace handle without deleting checkout files; the last handle anchoring a physical worktree remains protected for `close_worktree`.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- `bash` now uses ForgeRelay's process-session runtime instead of the Pi bash executor. It waits in the foreground for at most 300 seconds and, if still running, returns a `sessionId` without killing the process. `write_stdin` is available in regular tool modes to poll, wait, interact, or interrupt, and asynchronous completion is delivered once on a later tool result for the same workspace ID.
|
|
17
|
+
|
|
18
|
+
### Security
|
|
19
|
+
|
|
20
|
+
- Background-process ownership and completion delivery are scoped to the logical `workspaceId`; logical workspace cleanup is refused while that ID owns a running or unconsumed process completion, and idle-session scanning does not refresh stale activity timestamps.
|
|
21
|
+
|
|
22
|
+
## [0.2.3] - 2026-08-09
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Shell tools no longer carry a blanket prohibition against commands that modify files. `bash` and Codex `exec_command` may update ordinary project files when that is a natural part of the user's requested development task, including package managers, generators, and formatters.
|
|
27
|
+
|
|
28
|
+
### Security
|
|
29
|
+
|
|
30
|
+
- The Agent shell contract continues to prohibit mutation of security- or privilege-sensitive operating-system files and credential material such as `/etc/sudoers`, `/etc/passwd`, `/etc/shadow`, authentication policy, and SSH private keys; configuration-file changes through shell require an explicit user request.
|
|
31
|
+
|
|
7
32
|
## [0.2.2] - 2026-08-09
|
|
8
33
|
|
|
9
34
|
### Added
|
package/dist/hooks.js
CHANGED
|
@@ -92,6 +92,12 @@ export function parseHookConfig(value) {
|
|
|
92
92
|
}
|
|
93
93
|
return config;
|
|
94
94
|
}
|
|
95
|
+
function decorateToolResult(runner, workspaceId, result) {
|
|
96
|
+
const decorator = runner.decorateResult;
|
|
97
|
+
return workspaceId && typeof decorator === "function"
|
|
98
|
+
? decorator.call(runner, workspaceId, result)
|
|
99
|
+
: result;
|
|
100
|
+
}
|
|
95
101
|
export async function runToolWithHooks(runner, options) {
|
|
96
102
|
const basePayload = { tool: options.tool, ...(options.payload ?? {}) };
|
|
97
103
|
const executions = [];
|
|
@@ -108,7 +114,8 @@ export async function runToolWithHooks(runner, options) {
|
|
|
108
114
|
cwd: afterCwd,
|
|
109
115
|
payload: basePayload,
|
|
110
116
|
}));
|
|
111
|
-
|
|
117
|
+
const reported = attachHookReports(result, executions);
|
|
118
|
+
return decorateToolResult(runner, options.invocation.workspaceId, reported);
|
|
112
119
|
}
|
|
113
120
|
executions.push(...await runner.run("AfterTool", {
|
|
114
121
|
...options.invocation,
|
|
@@ -123,7 +130,8 @@ export async function runToolWithHooks(runner, options) {
|
|
|
123
130
|
payload: { ...basePayload, paths: changedPaths },
|
|
124
131
|
}));
|
|
125
132
|
}
|
|
126
|
-
|
|
133
|
+
const reported = attachHookReports(result, executions);
|
|
134
|
+
return decorateToolResult(runner, options.invocation.workspaceId, reported);
|
|
127
135
|
}
|
|
128
136
|
catch (error) {
|
|
129
137
|
if (error instanceof HookExecutionError) {
|
|
@@ -136,7 +144,8 @@ export async function runToolWithHooks(runner, options) {
|
|
|
136
144
|
errorType: error instanceof Error ? error.name : "Error",
|
|
137
145
|
},
|
|
138
146
|
}));
|
|
139
|
-
|
|
147
|
+
const reportedError = appendHookReportsToError(error, executions);
|
|
148
|
+
throw decorateToolResult(runner, options.invocation.workspaceId, reportedError);
|
|
140
149
|
}
|
|
141
150
|
}
|
|
142
151
|
export function attachHookReports(result, executions) {
|
|
@@ -188,10 +197,15 @@ export class HookRunner {
|
|
|
188
197
|
hooks;
|
|
189
198
|
logging;
|
|
190
199
|
baseEnv;
|
|
191
|
-
|
|
200
|
+
resultDecorator;
|
|
201
|
+
constructor(hooks, logging, baseEnv = process.env, resultDecorator) {
|
|
192
202
|
this.hooks = hooks;
|
|
193
203
|
this.logging = logging;
|
|
194
204
|
this.baseEnv = baseEnv;
|
|
205
|
+
this.resultDecorator = resultDecorator;
|
|
206
|
+
}
|
|
207
|
+
decorateResult(workspaceId, result) {
|
|
208
|
+
return (this.resultDecorator?.(workspaceId, result) ?? result);
|
|
195
209
|
}
|
|
196
210
|
async run(event, invocation) {
|
|
197
211
|
const projectRoot = event === "AfterWorktreeClose" && invocation.sourceRoot
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const toolNames = {
|
|
2
2
|
openWorkspace: "open_workspace",
|
|
3
|
+
closeWorkspace: "close_workspace",
|
|
3
4
|
closeWorktree: "close_worktree",
|
|
4
5
|
read: "read",
|
|
5
6
|
write: "write",
|
|
@@ -10,7 +11,11 @@ export const toolNames = {
|
|
|
10
11
|
glob: "glob",
|
|
11
12
|
ls: "ls",
|
|
12
13
|
shell: "bash",
|
|
14
|
+
writeStdin: "write_stdin",
|
|
13
15
|
};
|
|
16
|
+
export function buildShellMutationPolicy() {
|
|
17
|
+
return "Shell commands may modify ordinary project files when that is a natural part of the user's requested development task. Never use shell commands to modify security- or privilege-sensitive operating-system files or credential material such as /etc/sudoers, /etc/passwd, /etc/shadow, PAM or authentication policy, SSH private keys, or equivalent privileged system files. Modify configuration files through shell only when the user's request explicitly calls for that configuration change; do not infer permission merely because changing configuration would be convenient.";
|
|
18
|
+
}
|
|
14
19
|
export function buildServerInstructions(config, context = {}) {
|
|
15
20
|
return joinInstructions(capabilityContractInstructions(config, context), selectedWorkflowInstructions(config), config.appendInstructions);
|
|
16
21
|
}
|
|
@@ -21,6 +26,7 @@ export function buildToolDescriptions(config) {
|
|
|
21
26
|
const shellSurface = config.toolMode === "minimal"
|
|
22
27
|
? ` In minimal tool mode, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} are disabled, so shell commands may be used for equivalent search and directory inspection.`
|
|
23
28
|
: "";
|
|
29
|
+
const shellMutationPolicy = buildShellMutationPolicy();
|
|
24
30
|
return {
|
|
25
31
|
read: `Read a file inside an open workspace or the OS temp directory. Instruction files returned by ${toolNames.openWorkspace} and advertised skill files are also readable when applicable.${skillCapability} Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
26
32
|
write: `Create or completely overwrite a file inside an open workspace or the OS temp directory. Workspace paths may be relative; OS temp paths may be absolute. Call ${toolNames.openWorkspace} first and pass workspaceId.`,
|
|
@@ -28,19 +34,20 @@ export function buildToolDescriptions(config) {
|
|
|
28
34
|
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.`,
|
|
29
35
|
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.`,
|
|
30
36
|
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.`,
|
|
31
|
-
shell: `Run a shell command inside an open workspace.${shellSurface} Commands execute with the local user's authority; workspace filesystem containment does not make shell execution a sandbox.
|
|
37
|
+
shell: `Run a shell command inside an open workspace.${shellSurface} Commands execute with the local user's authority; workspace filesystem containment does not make shell execution a sandbox. ForgeRelay waits up to 300 seconds for bash, then returns a running process session without killing it; use ${toolNames.writeStdin} to poll, keep waiting, interact, or send Ctrl-C. Completed background commands are also reported with a later tool result for the same workspaceId. ${shellMutationPolicy} Call ${toolNames.openWorkspace} first and pass workspaceId. This capability should only be exposed behind strong authentication.`,
|
|
32
38
|
shellCommand: "Shell command to run with the local user's authority.",
|
|
33
39
|
};
|
|
34
40
|
}
|
|
35
41
|
function capabilityContractInstructions(config, context) {
|
|
36
42
|
const workspaceLifecycle = config.toolMode === "codex"
|
|
37
|
-
? `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout
|
|
38
|
-
: `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout
|
|
43
|
+
? `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout. Keep the workspaceId returned for this conversation stable. Different conversations normally receive separate logical workspaceIds even when they point at the same physical checkout or worktree; pass an existing workspaceId to ${toolNames.openWorkspace} only when the user wants to resume that logical workspace in this conversation. Only request a new logical workspace when the user explicitly asks. Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. Managed worktrees use dedicated forgerelay/* branches, not detached HEADs. When work in a managed worktree is complete and verified, call ${toolNames.closeWorktree}; it commits remaining worktree changes, fast-forwards the original target branch only when safe, then removes the worktree and its branch. If the target branch diverged or the source checkout is dirty, closing is refused and the worktree is preserved.`
|
|
44
|
+
: `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout. Keep the workspaceId returned for this conversation stable for later file, search, edit, write, rename, delete, show-changes, shell, and process-polling tools. Different conversations normally receive separate logical workspaceIds even when they point at the same physical checkout or worktree; pass an existing workspaceId to ${toolNames.openWorkspace} only when the user wants to resume that logical workspace in this conversation. Only request a new logical workspace when the user explicitly asks. If ${toolNames.openWorkspace} reports logical workspaces idle for more than two days, tell the user each workspaceId and let them decide whether to resume it or explicitly clean it up with ${toolNames.closeWorkspace}; do not close it automatically. Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel Git work. Managed worktrees use dedicated forgerelay/* branches, not detached HEADs. When work in a managed worktree is complete and verified, call ${toolNames.closeWorktree}; it commits remaining worktree changes, fast-forwards the original target branch only when safe, then removes the worktree and its branch. If the target branch diverged or the source checkout is dirty, closing is refused and the worktree is preserved.`;
|
|
39
45
|
const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in availableAgentsFiles, use ${toolNames.read} to inspect that instruction file and follow it.`;
|
|
40
46
|
const skills = config.skillsEnabled
|
|
41
47
|
? `When ${toolNames.openWorkspace} returns available skills and a task matches a skill, use ${toolNames.read} to read that skill's path before proceeding. Skill paths may be outside the workspace, but ${toolNames.read} only permits advertised SKILL.md files and files under already-loaded skill directories.`
|
|
42
48
|
: "";
|
|
43
49
|
const toolSurface = toolSurfaceInstructions(config);
|
|
50
|
+
const shellMutationPolicy = buildShellMutationPolicy();
|
|
44
51
|
const hooks = "When a ForgeRelay tool result reports Hook results, tell the user which meaningful hooks ran and whether they passed or blocked the operation. Do not claim the requested operation succeeded when a blocking hook prevented it.";
|
|
45
52
|
const artifact = config.artifactsEnabled && context.artifactDownloadSupported
|
|
46
53
|
? "When the user supplies or generates a file that is not present on the ForgeRelay host, use download_artifact with its native file value, the existing workspace ID, and a suitable relative destination path chosen from the user's request and project structure. The tool refuses to overwrite an existing destination and returns the normalized workspace-relative path. Use normal workspace tools when explicit inspection, replacement, movement, renaming, or deletion is needed. Do not recreate binary files with write/edit calls or place signed URLs, native file objects, base64 content, or invented host paths in shell commands or logs."
|
|
@@ -48,16 +55,16 @@ function capabilityContractInstructions(config, context) {
|
|
|
48
55
|
const showChanges = config.widgets === "changes"
|
|
49
56
|
? "If the turn successfully modifies files by creating, editing, overwriting, deleting, moving, or applying patches, call show_changes exactly once for that workspace after the final related file change and before your final response so the user can inspect the aggregate diff for that turn. Do not call it after every individual file change; do not skip it because individual file-change tools already returned diffs."
|
|
50
57
|
: "";
|
|
51
|
-
return joinInstructions(workspaceLifecycle, agents, skills, toolSurface, hooks, artifact, showChanges);
|
|
58
|
+
return joinInstructions(workspaceLifecycle, agents, skills, toolSurface, shellMutationPolicy, hooks, artifact, showChanges);
|
|
52
59
|
}
|
|
53
60
|
function toolSurfaceInstructions(config) {
|
|
54
61
|
if (config.toolMode === "codex") {
|
|
55
|
-
return `In codex tool mode, workspace file and command operations use ${toolNames.read}, ${toolNames.rename}, ${toolNames.delete}, apply_patch, exec_command, and
|
|
62
|
+
return `In codex tool mode, workspace file and command operations use ${toolNames.read}, ${toolNames.rename}, ${toolNames.delete}, apply_patch, exec_command, and ${toolNames.writeStdin}.`;
|
|
56
63
|
}
|
|
57
64
|
if (config.toolMode === "full") {
|
|
58
|
-
return `In full tool mode, dedicated ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} inspection tools are available alongside the core workspace tools.`;
|
|
65
|
+
return `In full tool mode, dedicated ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} inspection tools are available alongside the core workspace tools. ${toolNames.writeStdin} is available for running bash sessions.`;
|
|
59
66
|
}
|
|
60
|
-
return `In minimal tool mode, dedicated ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} inspection tools are disabled; the core workspace tools remain available.`;
|
|
67
|
+
return `In minimal tool mode, dedicated ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} inspection tools are disabled; the core workspace tools remain available, including ${toolNames.writeStdin} for running bash sessions.`;
|
|
61
68
|
}
|
|
62
69
|
function selectedWorkflowInstructions(config) {
|
|
63
70
|
if (config.workflowInstructions === false)
|
|
@@ -68,12 +75,12 @@ function selectedWorkflowInstructions(config) {
|
|
|
68
75
|
}
|
|
69
76
|
function defaultWorkflowInstructions(config) {
|
|
70
77
|
if (config.toolMode === "codex") {
|
|
71
|
-
return `Use ${toolNames.read} for direct file reads, ${toolNames.rename} and ${toolNames.delete} for direct path moves or removals, apply_patch for content modifications, exec_command for inspection, tests, builds, and other commands, and
|
|
78
|
+
return `Use ${toolNames.read} for direct file reads, ${toolNames.rename} and ${toolNames.delete} for direct path moves or removals, apply_patch for content modifications, exec_command for inspection, tests, builds, and other commands, and ${toolNames.writeStdin} to poll or interact with running processes.`;
|
|
72
79
|
}
|
|
73
80
|
const inspection = config.toolMode === "full"
|
|
74
81
|
? `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection.`
|
|
75
82
|
: `Use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection.`;
|
|
76
|
-
return joinInstructions(inspection, `Prefer ${toolNames.edit} for targeted content modifications, ${toolNames.write} only for new files or complete rewrites, ${toolNames.rename} for path moves, ${toolNames.delete} for removals, and ${toolNames.shell} for tests, builds, git inspection, package scripts, and commands that are better executed by the shell
|
|
83
|
+
return joinInstructions(inspection, `Prefer ${toolNames.edit} for targeted content modifications, ${toolNames.write} only for new files or complete rewrites, ${toolNames.rename} for path moves, ${toolNames.delete} for removals, and ${toolNames.shell} for tests, builds, git inspection, package scripts, generators, formatters, and commands that are better executed by the shell. If ${toolNames.shell} returns a running session, use ${toolNames.writeStdin} only when you need to poll, wait, interact, or interrupt it; otherwise you may continue other work and consume its completion notice from a later tool result.`);
|
|
77
84
|
}
|
|
78
85
|
function joinInstructions(...parts) {
|
|
79
86
|
return parts
|
package/dist/pi-tools.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createEditTool, createFindTool, createGrepTool, createLsTool, createReadTool, createWriteTool, } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { resolveCanonicalAllowedPath } from "./roots.js";
|
|
3
3
|
function toMcpContent(result) {
|
|
4
4
|
return result.content.map((content) => {
|
|
@@ -72,11 +72,3 @@ export async function listDirectoryTool(input, context) {
|
|
|
72
72
|
const tool = createLsTool(context.cwd);
|
|
73
73
|
return runTool((params) => tool.execute("list_directory", params), { ...input, path }, context);
|
|
74
74
|
}
|
|
75
|
-
export async function runShellTool(input, context) {
|
|
76
|
-
const tool = createBashTool(context.cwd);
|
|
77
|
-
const timeout = input.timeout === undefined ? 30 : Math.min(input.timeout, 300);
|
|
78
|
-
return runTool((params) => tool.execute("run_shell", params), {
|
|
79
|
-
command: input.command,
|
|
80
|
-
timeout,
|
|
81
|
-
}, context);
|
|
82
|
-
}
|
package/dist/process-sessions.js
CHANGED
|
@@ -3,11 +3,12 @@ import { resolveShellCommand, terminateProcessTree } from "./process-platform.js
|
|
|
3
3
|
const DEFAULT_EXEC_YIELD_MS = 10_000;
|
|
4
4
|
const DEFAULT_INTERACTIVE_YIELD_MS = 250;
|
|
5
5
|
const DEFAULT_POLL_YIELD_MS = 5_000;
|
|
6
|
-
const
|
|
7
|
-
const
|
|
6
|
+
const MAX_START_YIELD_MS = 300_000;
|
|
7
|
+
const MAX_COMMAND_YIELD_MS = 300_000;
|
|
8
|
+
const MAX_POLL_YIELD_MS = 300_000;
|
|
8
9
|
const DEFAULT_MAX_OUTPUT_TOKENS = 10_000;
|
|
9
10
|
const DEFAULT_BUFFER_CHARACTERS = 1_000_000;
|
|
10
|
-
const COMPLETED_SESSION_TTL_MS =
|
|
11
|
+
const COMPLETED_SESSION_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
11
12
|
const DEFAULT_COLUMNS = 80;
|
|
12
13
|
const DEFAULT_ROWS = 24;
|
|
13
14
|
function boundedInteger(value, fallback, maximum) {
|
|
@@ -28,13 +29,13 @@ function terminalSize(value, fallback) {
|
|
|
28
29
|
}
|
|
29
30
|
function processEnvironment(input) {
|
|
30
31
|
return {
|
|
31
|
-
...Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== undefined)),
|
|
32
|
+
...Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== undefined && (input?.codexCi || entry[0] !== "CODEX_CI"))),
|
|
32
33
|
NO_COLOR: "1",
|
|
33
34
|
TERM: "dumb",
|
|
34
35
|
PAGER: "cat",
|
|
35
36
|
GIT_PAGER: "cat",
|
|
36
37
|
GH_PAGER: "cat",
|
|
37
|
-
CODEX_CI: "1",
|
|
38
|
+
...(input?.codexCi ? { CODEX_CI: "1" } : {}),
|
|
38
39
|
LANG: process.env.LANG ?? "C.UTF-8",
|
|
39
40
|
LC_ALL: process.env.LC_ALL ?? "C.UTF-8",
|
|
40
41
|
...(input?.workspaceId ? { FORGERELAY_WORKSPACE_ID: input.workspaceId, DEVSPACE_WORKSPACE_ID: input.workspaceId } : {}),
|
|
@@ -130,12 +131,15 @@ function truncateOutput(output, maxCharacters) {
|
|
|
130
131
|
}
|
|
131
132
|
export class ProcessSessionManager {
|
|
132
133
|
sessions = new Map();
|
|
134
|
+
completedByWorkspace = new Map();
|
|
133
135
|
maxBufferCharacters;
|
|
134
136
|
completedSessionTtlMs;
|
|
137
|
+
maxStartYieldMs;
|
|
135
138
|
nextSessionId = 1;
|
|
136
139
|
constructor(options = {}) {
|
|
137
140
|
this.maxBufferCharacters = options.maxBufferCharacters ?? DEFAULT_BUFFER_CHARACTERS;
|
|
138
141
|
this.completedSessionTtlMs = options.completedSessionTtlMs ?? COMPLETED_SESSION_TTL_MS;
|
|
142
|
+
this.maxStartYieldMs = options.maxStartYieldMs ?? MAX_START_YIELD_MS;
|
|
139
143
|
}
|
|
140
144
|
async start(input) {
|
|
141
145
|
const session = this.createSession(input);
|
|
@@ -150,10 +154,12 @@ export class ProcessSessionManager {
|
|
|
150
154
|
this.sessions.delete(session.id);
|
|
151
155
|
throw error;
|
|
152
156
|
}
|
|
153
|
-
const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_EXEC_YIELD_MS,
|
|
157
|
+
const yieldTimeMs = boundedInteger(input.yieldTimeMs, DEFAULT_EXEC_YIELD_MS, this.maxStartYieldMs);
|
|
154
158
|
await this.waitForExit(session, yieldTimeMs);
|
|
159
|
+
if (session.running)
|
|
160
|
+
session.background = true;
|
|
155
161
|
const snapshot = this.consume(session, input.maxOutputTokens);
|
|
156
|
-
if (!
|
|
162
|
+
if (!snapshot.running)
|
|
157
163
|
this.removeSession(session.id);
|
|
158
164
|
return snapshot;
|
|
159
165
|
}
|
|
@@ -187,6 +193,26 @@ export class ProcessSessionManager {
|
|
|
187
193
|
this.removeSession(session.id);
|
|
188
194
|
return snapshot;
|
|
189
195
|
}
|
|
196
|
+
activeWorkspaceIds() {
|
|
197
|
+
return new Set([...this.sessions.values()].map((session) => session.workspaceId));
|
|
198
|
+
}
|
|
199
|
+
takeCompleted(workspaceId, maxOutputTokens, excludeSessionId) {
|
|
200
|
+
const sessionIds = this.completedByWorkspace.get(workspaceId) ?? [];
|
|
201
|
+
if (sessionIds.length === 0)
|
|
202
|
+
return [];
|
|
203
|
+
const completed = [];
|
|
204
|
+
for (const sessionId of sessionIds) {
|
|
205
|
+
if (sessionId === excludeSessionId)
|
|
206
|
+
continue;
|
|
207
|
+
const session = this.sessions.get(sessionId);
|
|
208
|
+
if (!session || session.running)
|
|
209
|
+
continue;
|
|
210
|
+
const snapshot = this.consume(session, maxOutputTokens);
|
|
211
|
+
completed.push({ ...snapshot, sessionId: session.id, command: session.command });
|
|
212
|
+
this.removeSession(session.id);
|
|
213
|
+
}
|
|
214
|
+
return completed;
|
|
215
|
+
}
|
|
190
216
|
terminate(workspaceId, sessionId) {
|
|
191
217
|
const session = this.getOwnedSession(workspaceId, sessionId);
|
|
192
218
|
if (session.running)
|
|
@@ -200,6 +226,7 @@ export class ProcessSessionManager {
|
|
|
200
226
|
session.process?.kill("SIGTERM");
|
|
201
227
|
}
|
|
202
228
|
this.sessions.clear();
|
|
229
|
+
this.completedByWorkspace.clear();
|
|
203
230
|
}
|
|
204
231
|
async waitForExit(session, yieldTimeMs) {
|
|
205
232
|
let timer;
|
|
@@ -224,11 +251,13 @@ export class ProcessSessionManager {
|
|
|
224
251
|
return {
|
|
225
252
|
id: this.nextSessionId++,
|
|
226
253
|
workspaceId: input.workspaceId,
|
|
254
|
+
command: input.command,
|
|
227
255
|
startedAt: Date.now(),
|
|
228
256
|
columns: terminalSize(input.columns, DEFAULT_COLUMNS),
|
|
229
257
|
rows: terminalSize(input.rows, DEFAULT_ROWS),
|
|
230
258
|
buffer: new HeadTailBuffer(this.maxBufferCharacters),
|
|
231
259
|
running: true,
|
|
260
|
+
background: false,
|
|
232
261
|
exitPromise,
|
|
233
262
|
resolveExit,
|
|
234
263
|
};
|
|
@@ -241,6 +270,7 @@ export class ProcessSessionManager {
|
|
|
241
270
|
env: processEnvironment({
|
|
242
271
|
workspaceId: input.workspaceId,
|
|
243
272
|
workspaceRoot: input.workspaceRoot,
|
|
273
|
+
codexCi: input.codexCi,
|
|
244
274
|
}),
|
|
245
275
|
stdio: "pipe",
|
|
246
276
|
windowsHide: true,
|
|
@@ -273,6 +303,7 @@ export class ProcessSessionManager {
|
|
|
273
303
|
env: processEnvironment({
|
|
274
304
|
workspaceId: input.workspaceId,
|
|
275
305
|
workspaceRoot: input.workspaceRoot,
|
|
306
|
+
codexCi: input.codexCi,
|
|
276
307
|
}),
|
|
277
308
|
name: "xterm-256color",
|
|
278
309
|
cols: session.columns,
|
|
@@ -299,7 +330,14 @@ export class ProcessSessionManager {
|
|
|
299
330
|
session.exitCode = exitCode;
|
|
300
331
|
session.signal = signal;
|
|
301
332
|
session.resolveExit();
|
|
302
|
-
|
|
333
|
+
if (session.background) {
|
|
334
|
+
const completed = this.completedByWorkspace.get(session.workspaceId) ?? [];
|
|
335
|
+
if (!completed.includes(session.id)) {
|
|
336
|
+
completed.push(session.id);
|
|
337
|
+
this.completedByWorkspace.set(session.workspaceId, completed);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
session.cleanupTimer = setTimeout(() => this.removeSession(session.id), this.completedSessionTtlMs);
|
|
303
341
|
session.cleanupTimer.unref();
|
|
304
342
|
}
|
|
305
343
|
append(session, output) {
|
|
@@ -333,5 +371,15 @@ export class ProcessSessionManager {
|
|
|
333
371
|
if (session?.cleanupTimer)
|
|
334
372
|
clearTimeout(session.cleanupTimer);
|
|
335
373
|
this.sessions.delete(sessionId);
|
|
374
|
+
if (!session)
|
|
375
|
+
return;
|
|
376
|
+
const completed = this.completedByWorkspace.get(session.workspaceId);
|
|
377
|
+
if (!completed)
|
|
378
|
+
return;
|
|
379
|
+
const remaining = completed.filter((id) => id !== sessionId);
|
|
380
|
+
if (remaining.length > 0)
|
|
381
|
+
this.completedByWorkspace.set(session.workspaceId, remaining);
|
|
382
|
+
else
|
|
383
|
+
this.completedByWorkspace.delete(session.workspaceId);
|
|
336
384
|
}
|
|
337
385
|
}
|