@akira-tl/forgerelay 0.4.6 → 0.5.0

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,30 @@ All notable ForgeRelay changes are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.0] - 2026-08-14
8
+
9
+ ### Added
10
+
11
+ - Added the production local Activity audit foundation: append-only Audit Events persist in ForgeRelay's existing SQLite state, queryable Activity Records survive server restarts and Workspace cleanup, and success, failure, and Hook-blocked outcomes retain immutable Workspace/Host Turn execution context for later lifecycle and UI releases.
12
+
13
+ ## [0.4.7] - 2026-08-11
14
+
15
+ ### Added
16
+
17
+ - 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`.
18
+
19
+ ### Changed
20
+
21
+ - 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.
22
+ - 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.
23
+ - 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.
24
+
25
+ ### Fixed
26
+
27
+ - 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.
28
+ - Corrected `ProcessManager` yield bounding so a configured maximum also caps the default feedback window rather than only explicit `yieldTimeMs` values.
29
+ - 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.
30
+
7
31
  ## [0.4.6] - 2026-08-11
8
32
 
9
33
  ### Added
package/README.md CHANGED
@@ -163,13 +163,13 @@ Hook 是 ForgeRelay 的自动生命周期规则。首选方式是一个 Hook 一
163
163
  "tool": "bash",
164
164
  "commandRegex": "git\\s+push\\s+origin\\s+v\\d+\\.\\d+\\.\\d+"
165
165
  },
166
- "command": "npm run release:verify",
167
- "timeoutSeconds": 300,
166
+ "command": "node scripts/release-proof.mjs check-hook",
167
+ "timeoutSeconds": 30,
168
168
  "report": true
169
169
  }
170
170
  ```
171
171
 
172
- 命中 `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` 可以隐藏不重要的成功报告,但阻断失败始终可见。
173
173
 
174
174
  旧的 inline `hooks` 和聚合 `hooks.json` 仍兼容;新配置建议都用独立 `hooks/*.json` 文件。
175
175
 
@@ -292,10 +292,11 @@ npm run release:major
292
292
  npm run release:verify
293
293
  ```
294
294
 
295
- Daily branch pushes do not run cloud CI. When preparing a release, run the full
296
- local release verification first. `release:verify` includes a focused parity pass
297
- in an isolated Node 22.19.0 environment with its own `npm ci`, matching the cloud
298
- CI runtime for native addons and high-risk LSP lifecycle tests. Pushing a matching
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
299
300
  `vX.Y.Z` tag to `Akira-TL/forgerelay` is the only cloud CI and publish trigger:
300
301
  GitHub Actions runs the reusable multi-platform CI, then publishes
301
302
  `@akira-tl/forgerelay` and creates the matching GitHub Release only after CI
@@ -0,0 +1,209 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { openDatabase } from "../db/client.js";
3
+ export class ActivityAuditStore {
4
+ database;
5
+ now;
6
+ constructor(stateDir, options = {}) {
7
+ this.database = openDatabase(stateDir);
8
+ this.now = options.now ?? (() => new Date());
9
+ }
10
+ append(input) {
11
+ return this.database.sqlite.transaction(() => {
12
+ const existing = this.readRows(input.activityId);
13
+ if (input.type === "started") {
14
+ if (existing.length > 0) {
15
+ throw new Error(`Activity ${input.activityId} already has audit events.`);
16
+ }
17
+ }
18
+ else if (existing.length === 0 || existing[0]?.event_type !== "started") {
19
+ throw new Error(`Activity ${input.activityId} must start before recording ${input.type}.`);
20
+ }
21
+ const sequence = existing.length + 1;
22
+ const id = `evt_${randomUUID().replaceAll("-", "")}`;
23
+ const createdAt = this.now().toISOString();
24
+ const row = eventInputToRow(input, { id, sequence, createdAt });
25
+ this.database.sqlite.prepare(`insert into activity_audit_events (
26
+ id,
27
+ activity_id,
28
+ sequence,
29
+ event_type,
30
+ turn_id,
31
+ conversation_scope_id,
32
+ tool,
33
+ workspace_id,
34
+ workspace_root,
35
+ workspace_mode,
36
+ workspace_source_root,
37
+ workspace_branch,
38
+ workspace_target_branch,
39
+ request_json,
40
+ result_json,
41
+ error,
42
+ created_at
43
+ ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(row.id, row.activity_id, row.sequence, row.event_type, row.turn_id, row.conversation_scope_id, row.tool, row.workspace_id, row.workspace_root, row.workspace_mode, row.workspace_source_root, row.workspace_branch, row.workspace_target_branch, row.request_json, row.result_json, row.error, row.created_at);
44
+ return rowToEvent(row);
45
+ })();
46
+ }
47
+ listEvents(activityId) {
48
+ return this.readRows(activityId).map(rowToEvent);
49
+ }
50
+ getActivity(activityId) {
51
+ const events = this.listEvents(activityId);
52
+ const started = events[0];
53
+ if (!started || started.type !== "started")
54
+ return undefined;
55
+ let state = "executing";
56
+ let result;
57
+ let error;
58
+ let updatedAt = started.createdAt;
59
+ for (const event of events.slice(1)) {
60
+ updatedAt = event.createdAt;
61
+ switch (event.type) {
62
+ case "started":
63
+ break;
64
+ case "succeeded":
65
+ state = "done";
66
+ result = event.result;
67
+ error = undefined;
68
+ break;
69
+ case "failed":
70
+ state = "failed";
71
+ result = event.result;
72
+ error = event.error;
73
+ break;
74
+ case "blocked":
75
+ state = "blocked";
76
+ result = undefined;
77
+ error = event.error;
78
+ break;
79
+ }
80
+ }
81
+ return {
82
+ activityId: started.activityId,
83
+ turnId: started.turnId,
84
+ ...(started.conversationScopeId ? { conversationScopeId: started.conversationScopeId } : {}),
85
+ tool: started.tool,
86
+ workspace: started.workspace,
87
+ state,
88
+ ...(started.request !== undefined ? { request: started.request } : {}),
89
+ ...(result !== undefined ? { result } : {}),
90
+ ...(error !== undefined ? { error } : {}),
91
+ startedAt: started.createdAt,
92
+ updatedAt,
93
+ };
94
+ }
95
+ close() {
96
+ this.database.close();
97
+ }
98
+ readRows(activityId) {
99
+ return this.database.sqlite.prepare(`select * from activity_audit_events
100
+ where activity_id = ?
101
+ order by sequence asc`).all(activityId);
102
+ }
103
+ }
104
+ function eventInputToRow(input, identity) {
105
+ if (input.type === "started") {
106
+ return {
107
+ id: identity.id,
108
+ activity_id: input.activityId,
109
+ sequence: identity.sequence,
110
+ event_type: input.type,
111
+ turn_id: input.turnId,
112
+ conversation_scope_id: input.conversationScopeId ?? null,
113
+ tool: input.tool,
114
+ workspace_id: input.workspace.id ?? null,
115
+ workspace_root: input.workspace.root,
116
+ workspace_mode: input.workspace.mode,
117
+ workspace_source_root: input.workspace.sourceRoot ?? null,
118
+ workspace_branch: input.workspace.branch ?? null,
119
+ workspace_target_branch: input.workspace.targetBranch ?? null,
120
+ request_json: serializeJson(input.request),
121
+ result_json: null,
122
+ error: null,
123
+ created_at: identity.createdAt,
124
+ };
125
+ }
126
+ return {
127
+ id: identity.id,
128
+ activity_id: input.activityId,
129
+ sequence: identity.sequence,
130
+ event_type: input.type,
131
+ turn_id: null,
132
+ conversation_scope_id: null,
133
+ tool: null,
134
+ workspace_id: null,
135
+ workspace_root: null,
136
+ workspace_mode: null,
137
+ workspace_source_root: null,
138
+ workspace_branch: null,
139
+ workspace_target_branch: null,
140
+ request_json: null,
141
+ result_json: "result" in input ? serializeJson(input.result) : null,
142
+ error: "error" in input ? input.error : null,
143
+ created_at: identity.createdAt,
144
+ };
145
+ }
146
+ function rowToEvent(row) {
147
+ const base = {
148
+ id: row.id,
149
+ activityId: row.activity_id,
150
+ sequence: row.sequence,
151
+ createdAt: row.created_at,
152
+ };
153
+ switch (row.event_type) {
154
+ case "started":
155
+ if (!row.turn_id || !row.tool || !row.workspace_root || !isWorkspaceMode(row.workspace_mode)) {
156
+ throw new Error(`Activity audit start event ${row.id} is missing required context.`);
157
+ }
158
+ return {
159
+ ...base,
160
+ type: "started",
161
+ turnId: row.turn_id,
162
+ ...(row.conversation_scope_id ? { conversationScopeId: row.conversation_scope_id } : {}),
163
+ tool: row.tool,
164
+ workspace: {
165
+ ...(row.workspace_id ? { id: row.workspace_id } : {}),
166
+ root: row.workspace_root,
167
+ mode: row.workspace_mode,
168
+ ...(row.workspace_source_root ? { sourceRoot: row.workspace_source_root } : {}),
169
+ ...(row.workspace_branch ? { branch: row.workspace_branch } : {}),
170
+ ...(row.workspace_target_branch ? { targetBranch: row.workspace_target_branch } : {}),
171
+ },
172
+ ...(row.request_json !== null ? { request: parseJson(row.request_json) } : {}),
173
+ };
174
+ case "succeeded":
175
+ return {
176
+ ...base,
177
+ type: "succeeded",
178
+ result: parseJson(row.result_json),
179
+ };
180
+ case "failed":
181
+ if (!row.error)
182
+ throw new Error(`Activity audit failed event ${row.id} is missing an error.`);
183
+ return {
184
+ ...base,
185
+ type: "failed",
186
+ result: parseJson(row.result_json),
187
+ error: row.error,
188
+ };
189
+ case "blocked":
190
+ if (!row.error)
191
+ throw new Error(`Activity audit blocked event ${row.id} is missing an error.`);
192
+ return {
193
+ ...base,
194
+ type: "blocked",
195
+ error: row.error,
196
+ };
197
+ default:
198
+ throw new Error(`Unknown Activity audit event type: ${row.event_type}`);
199
+ }
200
+ }
201
+ function isWorkspaceMode(value) {
202
+ return value === "checkout" || value === "worktree";
203
+ }
204
+ function serializeJson(value) {
205
+ return value === undefined ? null : JSON.stringify(value);
206
+ }
207
+ function parseJson(value) {
208
+ return value === null ? undefined : JSON.parse(value);
209
+ }
@@ -34,6 +34,11 @@ const migrations = [
34
34
  name: "workspace-context-deliveries",
35
35
  up: migrateWorkspaceContextDeliveries,
36
36
  },
37
+ {
38
+ version: 8,
39
+ name: "activity-audit",
40
+ up: migrateActivityAudit,
41
+ },
37
42
  ];
38
43
  export function migrateDatabase(sqlite) {
39
44
  const migrate = sqlite.transaction(() => {
@@ -208,6 +213,41 @@ function migrateWorkspaceContextDeliveries(sqlite) {
208
213
  on workspace_context_deliveries(delivered_at desc);
209
214
  `);
210
215
  }
216
+ function migrateActivityAudit(sqlite) {
217
+ sqlite.exec(`
218
+ create table if not exists activity_audit_events (
219
+ id text primary key,
220
+ activity_id text not null,
221
+ sequence integer not null,
222
+ event_type text not null,
223
+ turn_id text,
224
+ conversation_scope_id text,
225
+ tool text,
226
+ workspace_id text,
227
+ workspace_root text,
228
+ workspace_mode text,
229
+ workspace_source_root text,
230
+ workspace_branch text,
231
+ workspace_target_branch text,
232
+ request_json text,
233
+ result_json text,
234
+ error text,
235
+ created_at text not null
236
+ );
237
+
238
+ create unique index if not exists activity_audit_events_activity_sequence_unique_idx
239
+ on activity_audit_events(activity_id, sequence);
240
+
241
+ create index if not exists activity_audit_events_activity_idx
242
+ on activity_audit_events(activity_id, sequence);
243
+
244
+ create index if not exists activity_audit_events_turn_idx
245
+ on activity_audit_events(turn_id, created_at);
246
+
247
+ create index if not exists activity_audit_events_created_idx
248
+ on activity_audit_events(created_at);
249
+ `);
250
+ }
211
251
  function addColumnIfMissing(sqlite, table, column, definition) {
212
252
  const columns = sqlite.prepare(`pragma table_info(${table})`).all();
213
253
  if (columns.some((existingColumn) => existingColumn.name === column))
package/dist/db/schema.js CHANGED
@@ -1,4 +1,4 @@
1
- import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
1
+ import { index, integer, primaryKey, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
2
2
  export const workspaceSessions = sqliteTable("workspace_sessions", {
3
3
  id: text("id").primaryKey(),
4
4
  root: text("root").notNull(),
@@ -73,6 +73,30 @@ export const oauthRefreshTokens = sqliteTable("oauth_refresh_tokens", {
73
73
  expiresAt: integer("expires_at").notNull(),
74
74
  resource: text("resource"),
75
75
  });
76
+ export const activityAuditEvents = sqliteTable("activity_audit_events", {
77
+ id: text("id").primaryKey(),
78
+ activityId: text("activity_id").notNull(),
79
+ sequence: integer("sequence").notNull(),
80
+ eventType: text("event_type").notNull(),
81
+ turnId: text("turn_id"),
82
+ conversationScopeId: text("conversation_scope_id"),
83
+ tool: text("tool"),
84
+ workspaceId: text("workspace_id"),
85
+ workspaceRoot: text("workspace_root"),
86
+ workspaceMode: text("workspace_mode"),
87
+ workspaceSourceRoot: text("workspace_source_root"),
88
+ workspaceBranch: text("workspace_branch"),
89
+ workspaceTargetBranch: text("workspace_target_branch"),
90
+ requestJson: text("request_json"),
91
+ resultJson: text("result_json"),
92
+ error: text("error"),
93
+ createdAt: text("created_at").notNull(),
94
+ }, (table) => [
95
+ uniqueIndex("activity_audit_events_activity_sequence_unique_idx").on(table.activityId, table.sequence),
96
+ index("activity_audit_events_activity_idx").on(table.activityId, table.sequence),
97
+ index("activity_audit_events_turn_idx").on(table.turnId, table.createdAt),
98
+ index("activity_audit_events_created_idx").on(table.createdAt),
99
+ ]);
76
100
  export const localAgentSessions = sqliteTable("local_agent_sessions", {
77
101
  id: text("id").primaryKey(),
78
102
  workspaceId: text("workspace_id"),
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
  });
@@ -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
  }