@akira-tl/forgerelay 0.2.3 → 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 CHANGED
@@ -4,6 +4,21 @@ 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
+
7
22
  ## [0.2.3] - 2026-08-09
8
23
 
9
24
  ### Changed
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
- return attachHookReports(result, executions);
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
- return attachHookReports(result, executions);
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
- throw appendHookReportsToError(error, executions);
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
- constructor(hooks, logging, baseEnv = process.env) {
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,6 +11,7 @@ export const toolNames = {
10
11
  glob: "glob",
11
12
  ls: "ls",
12
13
  shell: "bash",
14
+ writeStdin: "write_stdin",
13
15
  };
14
16
  export function buildShellMutationPolicy() {
15
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.";
@@ -32,14 +34,14 @@ export function buildToolDescriptions(config) {
32
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.`,
33
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.`,
34
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.`,
35
- 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. ${shellMutationPolicy} Call ${toolNames.openWorkspace} first and pass workspaceId. This capability should only be exposed behind strong authentication.`,
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.`,
36
38
  shellCommand: "Shell command to run with the local user's authority.",
37
39
  };
38
40
  }
39
41
  function capabilityContractInstructions(config, context) {
40
42
  const workspaceLifecycle = config.toolMode === "codex"
41
- ? `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout and reuse its workspaceId. Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel 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.`
42
- : `Use ForgeRelay as a local coding workspace. Default to the user's existing checkout and reuse its workspaceId for later file, search, edit, write, rename, delete, show-changes, and shell tools. Only open mode=\"worktree\" when the user explicitly asks for isolated or parallel 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.`;
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.`;
43
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.`;
44
46
  const skills = config.skillsEnabled
45
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.`
@@ -57,12 +59,12 @@ function capabilityContractInstructions(config, context) {
57
59
  }
58
60
  function toolSurfaceInstructions(config) {
59
61
  if (config.toolMode === "codex") {
60
- return `In codex tool mode, workspace file and command operations use ${toolNames.read}, ${toolNames.rename}, ${toolNames.delete}, apply_patch, exec_command, and write_stdin.`;
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}.`;
61
63
  }
62
64
  if (config.toolMode === "full") {
63
- 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.`;
64
66
  }
65
- 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.`;
66
68
  }
67
69
  function selectedWorkflowInstructions(config) {
68
70
  if (config.workflowInstructions === false)
@@ -73,12 +75,12 @@ function selectedWorkflowInstructions(config) {
73
75
  }
74
76
  function defaultWorkflowInstructions(config) {
75
77
  if (config.toolMode === "codex") {
76
- 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 write_stdin to poll or interact with running processes.`;
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.`;
77
79
  }
78
80
  const inspection = config.toolMode === "full"
79
81
  ? `Prefer ${toolNames.read}, ${toolNames.grep}, ${toolNames.glob}, and ${toolNames.ls} for file inspection.`
80
82
  : `Use ${toolNames.shell} with command-line tools such as grep, rg, find, ls, and tree for search and directory inspection.`;
81
- 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.`);
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.`);
82
84
  }
83
85
  function joinInstructions(...parts) {
84
86
  return parts
package/dist/pi-tools.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createBashTool, createEditTool, createFindTool, createGrepTool, createLsTool, createReadTool, createWriteTool, } from "@earendil-works/pi-coding-agent";
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
- }
@@ -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 MAX_COMMAND_YIELD_MS = 30_000;
7
- const MAX_POLL_YIELD_MS = 110_000;
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 = 5 * 60 * 1_000;
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, MAX_COMMAND_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 (!session.running)
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
- session.cleanupTimer = setTimeout(() => this.sessions.delete(session.id), this.completedSessionTtlMs);
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
  }