@howaboua/pi-codex-conversion 1.0.10 → 1.0.11

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/README.md CHANGED
@@ -55,11 +55,13 @@ npm run check
55
55
  - `rg --files src | head -n 50` -> `Explored / List src`
56
56
  - `cat README.md` -> `Explored / Read README.md`
57
57
  - `exec_command({ cmd: "npm test", yield_time_ms: 1000 })` may return `session_id`, then continue with `write_stdin`
58
- - `write_stdin({ session_id, chars: "" })` renders like `Waited for background terminal`
58
+ - for short or non-interactive commands, omitting `yield_time_ms` is preferred; tiny non-interactive waits are clamped upward to avoid unnecessary follow-up calls
59
+ - `write_stdin({ session_id, chars: "" })` renders like `Waited for background terminal` and is meant for occasional polling, not tight repoll loops
59
60
  - `write_stdin({ session_id, chars: "y\\n" })` renders like `Interacted with background terminal`
60
61
  - `view_image({ path: "/absolute/path/to/screenshot.png" })` is available on image-capable models
61
62
  - `web_search` is surfaced only on `openai-codex`, and the adapter rewrites it into the native OpenAI Responses `type: "web_search"` payload instead of executing a local function tool
62
63
  - when native web search is available, the adapter shows a one-time session notice; individual searches are not surfaced because Pi does not expose native web-search execution events to extensions
64
+ - `apply_patch` partial failures stay inline in the patch row so successful and failed file entries can be seen together
63
65
 
64
66
  Raw command output is still available by expanding the tool result.
65
67
 
@@ -136,7 +138,10 @@ That keeps the prompt much closer to `pi-mono` while still steering the model to
136
138
  - `view_image` resolves paths against the active session cwd and only exposes `detail: "original"` for Codex-family image-capable models.
137
139
  - `web_search` is exposed only for the `openai-codex` provider and is forwarded as the native OpenAI Codex Responses web search tool.
138
140
  - `apply_patch` paths stay restricted to the current working directory.
141
+ - partial `apply_patch` failures stay in the original patch block and highlight the failed entry instead of adding a second warning row.
139
142
  - `exec_command` / `write_stdin` use a custom PTY-backed session manager via `node-pty` for interactive sessions.
143
+ - tiny `exec_command` waits are clamped for non-interactive commands so short runs do not burn an avoidable follow-up tool call.
144
+ - empty `write_stdin` polls are clamped to a meaningful minimum wait so long-running processes are not repolled too aggressively.
140
145
  - PTY output handling applies basic terminal rewrite semantics (`\r`, `\b`, erase-in-line, and common escape cleanup) so interactive redraws replay sensibly.
141
146
  - Skills inventory is reintroduced in a Codex-style section when Pi's composed prompt already exposed the underlying Pi skills inventory.
142
147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@howaboua/pi-codex-conversion",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
4
4
  "description": "Codex-oriented tool and prompt adapter for pi coding agent",
5
5
  "type": "module",
6
6
  "repository": {
package/src/patch/core.ts CHANGED
@@ -1,8 +1,36 @@
1
- import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import * as fs from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { parsePatchActions, parseUpdateFile } from "./parser.ts";
4
4
  import { openFileAtPath, pathExists, removeFileAtPath, resolvePatchPath, writeFileAtPath } from "./paths.ts";
5
- import { DiffError, type ExecutePatchResult, type ParsedPatchAction, type ParserState, type PatchAction } from "./types.ts";
5
+ import { DiffError, ExecutePatchError, type ExecutePatchResult, type ParsedPatchAction, type ParserState, type PatchAction } from "./types.ts";
6
+
7
+ export const patchFsOps = {
8
+ mkdirSync: fs.mkdirSync,
9
+ writeFileSync: fs.writeFileSync,
10
+ unlinkSync: fs.unlinkSync,
11
+ };
12
+
13
+ function buildExecutePatchResult({
14
+ changedFiles,
15
+ createdFiles,
16
+ deletedFiles,
17
+ movedFiles,
18
+ fuzz,
19
+ }: {
20
+ changedFiles: Set<string>;
21
+ createdFiles: Set<string>;
22
+ deletedFiles: Set<string>;
23
+ movedFiles: Set<string>;
24
+ fuzz: number;
25
+ }): ExecutePatchResult {
26
+ return {
27
+ changedFiles: [...changedFiles],
28
+ createdFiles: [...createdFiles],
29
+ deletedFiles: [...deletedFiles],
30
+ movedFiles: [...movedFiles],
31
+ fuzz,
32
+ };
33
+ }
6
34
 
7
35
  function splitFileLines(text: string): string[] {
8
36
  const lines = text.split("\n");
@@ -103,21 +131,23 @@ function applyMove({
103
131
  const toAbsolutePath = resolvePatchPath({ cwd, patchPath: movePath });
104
132
  const destinationExisted = pathExists({ cwd, path: movePath });
105
133
 
106
- mkdirSync(dirname(toAbsolutePath), { recursive: true });
107
- writeFileSync(toAbsolutePath, content, "utf8");
108
- if (fromAbsolutePath !== toAbsolutePath) {
109
- unlinkSync(fromAbsolutePath);
110
- }
111
-
112
- changedFiles.add(path);
134
+ patchFsOps.mkdirSync(dirname(toAbsolutePath), { recursive: true });
135
+ patchFsOps.writeFileSync(toAbsolutePath, content, "utf8");
113
136
  changedFiles.add(movePath);
114
- movedFiles.add(`${path} -> ${movePath}`);
115
137
  if (!destinationExisted) {
116
138
  createdFiles.add(movePath);
117
139
  }
140
+
118
141
  if (fromAbsolutePath !== toAbsolutePath) {
142
+ patchFsOps.unlinkSync(fromAbsolutePath);
143
+ changedFiles.add(path);
144
+ movedFiles.add(`${path} -> ${movePath}`);
119
145
  deletedFiles.add(path);
146
+ return;
120
147
  }
148
+
149
+ changedFiles.add(path);
150
+ movedFiles.add(`${path} -> ${movePath}`);
121
151
  }
122
152
 
123
153
  function applyAction({
@@ -200,21 +230,36 @@ export function executePatch({ cwd, patchText }: { cwd: string; patchText: strin
200
230
  let fuzz = 0;
201
231
 
202
232
  for (const action of actions) {
203
- fuzz += applyAction({
204
- cwd,
205
- action,
206
- changedFiles,
207
- createdFiles,
208
- deletedFiles,
209
- movedFiles,
210
- });
233
+ try {
234
+ fuzz += applyAction({
235
+ cwd,
236
+ action,
237
+ changedFiles,
238
+ createdFiles,
239
+ deletedFiles,
240
+ movedFiles,
241
+ });
242
+ } catch (error) {
243
+ const message = error instanceof Error ? error.message : String(error);
244
+ throw new ExecutePatchError(
245
+ message,
246
+ buildExecutePatchResult({
247
+ changedFiles,
248
+ createdFiles,
249
+ deletedFiles,
250
+ movedFiles,
251
+ fuzz,
252
+ }),
253
+ action,
254
+ );
255
+ }
211
256
  }
212
257
 
213
- return {
214
- changedFiles: [...changedFiles],
215
- createdFiles: [...createdFiles],
216
- deletedFiles: [...deletedFiles],
217
- movedFiles: [...movedFiles],
258
+ return buildExecutePatchResult({
259
+ changedFiles,
260
+ createdFiles,
261
+ deletedFiles,
262
+ movedFiles,
218
263
  fuzz,
219
- };
264
+ });
220
265
  }
@@ -42,3 +42,25 @@ export class DiffError extends Error {
42
42
  this.name = "DiffError";
43
43
  }
44
44
  }
45
+
46
+ export class ExecutePatchError extends DiffError {
47
+ result: ExecutePatchResult;
48
+ failedAction?: ParsedPatchAction;
49
+
50
+ constructor(message: string, result: ExecutePatchResult, failedAction?: ParsedPatchAction) {
51
+ super(message);
52
+ this.name = "ExecutePatchError";
53
+ this.result = result;
54
+ this.failedAction = failedAction;
55
+ }
56
+
57
+ hasPartialSuccess(): boolean {
58
+ return (
59
+ this.result.changedFiles.length > 0 ||
60
+ this.result.createdFiles.length > 0 ||
61
+ this.result.deletedFiles.length > 0 ||
62
+ this.result.movedFiles.length > 0 ||
63
+ this.result.fuzz > 0
64
+ );
65
+ }
66
+ }
@@ -6,9 +6,12 @@ export interface PromptSkill {
6
6
 
7
7
  const CODEX_GUIDELINES = [
8
8
  "Prefer a single `apply_patch` call that updates all related files together when one coherent patch will do.",
9
+ "When making coordinated edits across multiple files, include them in one `apply_patch` call instead of splitting them into separate patches.",
9
10
  "When multiple tool calls are independent, emit them together so they can execute in parallel instead of serializing them.",
10
11
  "Use `parallel` only when tool calls are independent and can safely run at the same time.",
11
12
  "Use `write_stdin` when an exec session returns `session_id`, and continue until `exit_code` is present.",
13
+ "For short or non-interactive commands, prefer the default `exec_command` wait instead of a tiny `yield_time_ms` that forces an extra follow-up call.",
14
+ "When polling a running exec session with empty `chars`, wait meaningfully between polls and do not repeatedly poll by reflex.",
12
15
  "Do not request `tty` unless interactive terminal behavior is required.",
13
16
  ];
14
17
 
@@ -38,14 +38,14 @@ export function formatApplyPatchSummary(patchText: string, cwd = process.cwd()):
38
38
 
39
39
  if (files.length === 1) {
40
40
  const [file] = files;
41
- lines.push(`${bulletHeader(file.verb, renderPath(file.path, file.movePath, cwd))} ${renderCounts(file.added, file.removed)}`);
41
+ lines.push(`${bulletHeader(file.verb, formatPatchTarget(file.path, file.movePath, cwd))} ${renderCounts(file.added, file.removed)}`);
42
42
  return lines.join("\n");
43
43
  }
44
44
 
45
45
  lines.push(`${bulletHeader("Edited", `${files.length} files`)} ${renderCounts(totalAdded, totalRemoved)}`);
46
46
  for (const [index, file] of files.entries()) {
47
47
  const prefix = index === 0 ? " └ " : " ";
48
- lines.push(`${prefix}${renderPath(file.path, file.movePath, cwd)} ${renderCounts(file.added, file.removed)}`);
48
+ lines.push(`${prefix}${formatPatchTarget(file.path, file.movePath, cwd)} ${renderCounts(file.added, file.removed)}`);
49
49
  }
50
50
 
51
51
  return lines.join("\n");
@@ -70,7 +70,7 @@ export function formatApplyPatchCall(patchText: string, cwd = process.cwd()): st
70
70
 
71
71
  if (files.length === 1) {
72
72
  const [file] = files;
73
- lines.push(`${bulletHeader(file.verb, renderPath(file.path, file.movePath, cwd))} ${renderCounts(file.added, file.removed)}`);
73
+ lines.push(`${bulletHeader(file.verb, formatPatchTarget(file.path, file.movePath, cwd))} ${renderCounts(file.added, file.removed)}`);
74
74
  lines.push(...file.lines.map((line) => formatPreviewLine(line, file.lines)));
75
75
  return lines.join("\n");
76
76
  }
@@ -80,7 +80,7 @@ export function formatApplyPatchCall(patchText: string, cwd = process.cwd()): st
80
80
  if (index > 0) {
81
81
  lines.push("");
82
82
  }
83
- lines.push(` └ ${renderPath(file.path, file.movePath, cwd)} ${renderCounts(file.added, file.removed)}`);
83
+ lines.push(` └ ${formatPatchTarget(file.path, file.movePath, cwd)} ${renderCounts(file.added, file.removed)}`);
84
84
  lines.push(...file.lines.map((line) => formatPreviewLine(line, file.lines)));
85
85
  }
86
86
 
@@ -106,7 +106,7 @@ export function renderApplyPatchCall(patchText: string, cwd = process.cwd()): st
106
106
 
107
107
  if (files.length === 1) {
108
108
  const [file] = files;
109
- lines.push(`${bulletHeader(file.verb, renderPath(file.path, file.movePath, cwd))} ${renderCounts(file.added, file.removed)}`);
109
+ lines.push(`${bulletHeader(file.verb, formatPatchTarget(file.path, file.movePath, cwd))} ${renderCounts(file.added, file.removed)}`);
110
110
  lines.push(...renderPreviewLines(file.lines));
111
111
  return lines.join("\n");
112
112
  }
@@ -116,7 +116,7 @@ export function renderApplyPatchCall(patchText: string, cwd = process.cwd()): st
116
116
  if (index > 0) {
117
117
  lines.push("");
118
118
  }
119
- lines.push(` └ ${renderPath(file.path, file.movePath, cwd)} ${renderCounts(file.added, file.removed)}`);
119
+ lines.push(` └ ${formatPatchTarget(file.path, file.movePath, cwd)} ${renderCounts(file.added, file.removed)}`);
120
120
  lines.push(...renderPreviewLines(file.lines));
121
121
  }
122
122
 
@@ -303,7 +303,7 @@ function findSequence(lines: string[], context: string[], start: number, normali
303
303
  return -1;
304
304
  }
305
305
 
306
- function renderPath(path: string, movePath: string | undefined, cwd: string): string {
306
+ export function formatPatchTarget(path: string, movePath: string | undefined, cwd: string): string {
307
307
  const from = displayPath(path, cwd);
308
308
  if (!movePath) {
309
309
  return from;
@@ -2,8 +2,8 @@ import { Type } from "@sinclair/typebox";
2
2
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
3
  import { Container, Text } from "@mariozechner/pi-tui";
4
4
  import { executePatch } from "../patch/core.ts";
5
- import type { ExecutePatchResult } from "../patch/types.ts";
6
- import { formatApplyPatchSummary, renderApplyPatchCall } from "./apply-patch-rendering.ts";
5
+ import { ExecutePatchError, type ExecutePatchResult } from "../patch/types.ts";
6
+ import { formatApplyPatchSummary, formatPatchTarget, renderApplyPatchCall } from "./apply-patch-rendering.ts";
7
7
 
8
8
  const APPLY_PATCH_PARAMETERS = Type.Object({
9
9
  input: Type.String({
@@ -13,10 +13,27 @@ const APPLY_PATCH_PARAMETERS = Type.Object({
13
13
 
14
14
  interface ApplyPatchRenderState {
15
15
  cwd: string;
16
+ patchText: string;
16
17
  collapsed: string;
17
18
  expanded: string;
19
+ status: "pending" | "partial_failure";
20
+ failedTarget?: string;
18
21
  }
19
22
 
23
+ interface ApplyPatchSuccessDetails {
24
+ status: "success";
25
+ result: ExecutePatchResult;
26
+ }
27
+
28
+ interface ApplyPatchPartialFailureDetails {
29
+ status: "partial_failure";
30
+ result: ExecutePatchResult;
31
+ error: string;
32
+ failedTarget?: string;
33
+ }
34
+
35
+ type ApplyPatchToolDetails = ApplyPatchSuccessDetails | ApplyPatchPartialFailureDetails;
36
+
20
37
  const applyPatchRenderStates = new Map<string, ApplyPatchRenderState>();
21
38
 
22
39
  interface ApplyPatchRenderContextLike {
@@ -33,8 +50,104 @@ function parseApplyPatchParams(params: unknown): { patchText: string } {
33
50
  return { patchText: params.input };
34
51
  }
35
52
 
36
- function isExecutePatchResult(details: unknown): details is ExecutePatchResult {
37
- return typeof details === "object" && details !== null;
53
+ function isApplyPatchToolDetails(details: unknown): details is ApplyPatchToolDetails {
54
+ return typeof details === "object" && details !== null && "status" in details && "result" in details;
55
+ }
56
+
57
+ function setApplyPatchRenderState(
58
+ toolCallId: string,
59
+ patchText: string,
60
+ cwd: string,
61
+ status: "pending" | "partial_failure" = "pending",
62
+ failedTarget?: string,
63
+ ): void {
64
+ const collapsed = formatApplyPatchSummary(patchText, cwd);
65
+ const expanded = renderApplyPatchCall(patchText, cwd);
66
+ applyPatchRenderStates.set(toolCallId, {
67
+ cwd,
68
+ patchText,
69
+ collapsed,
70
+ expanded,
71
+ status,
72
+ failedTarget,
73
+ });
74
+ }
75
+
76
+ function markApplyPatchPartialFailure(toolCallId: string, failedTarget?: string): void {
77
+ const existing = applyPatchRenderStates.get(toolCallId);
78
+ if (!existing) {
79
+ return;
80
+ }
81
+ applyPatchRenderStates.set(toolCallId, {
82
+ ...existing,
83
+ status: "partial_failure",
84
+ failedTarget,
85
+ });
86
+ }
87
+
88
+ function renderPartialFailureCall(
89
+ text: string,
90
+ theme: { fg(role: string, text: string): string },
91
+ failedTarget?: string,
92
+ ): string {
93
+ const lines = text.split("\n");
94
+ if (lines.length === 0) {
95
+ return theme.fg("warning", "• Edit partially failed");
96
+ }
97
+ lines[0] = lines[0].replace(/^• (Added|Edited|Deleted)\b/, "• Edit partially failed");
98
+ const failedLineIndexes = new Set<number>();
99
+ if (failedTarget) {
100
+ for (let i = 0; i < lines.length; i += 1) {
101
+ const failedLine = markFailedTargetLine(lines[i], failedTarget);
102
+ if (failedLine) {
103
+ lines[i] = failedLine;
104
+ failedLineIndexes.add(i);
105
+ }
106
+ }
107
+ }
108
+ return lines
109
+ .map((line, index) => {
110
+ if (failedLineIndexes.has(index)) {
111
+ return theme.fg("error", line);
112
+ }
113
+ if (index === 0) {
114
+ return theme.fg("warning", line);
115
+ }
116
+ return line;
117
+ })
118
+ .join("\n");
119
+ }
120
+
121
+ function markFailedTargetLine(line: string, failedTarget: string): string | undefined {
122
+ const suffixMatch = line.match(/ \(\+\d+ -\d+\)$/);
123
+ if (!suffixMatch) {
124
+ return undefined;
125
+ }
126
+ const suffix = suffixMatch[0];
127
+ const prefixAndTarget = line.slice(0, -suffix.length);
128
+ const candidatePrefixes = ["• Edit partially failed ", "• Added ", "• Edited ", "• Deleted ", " └ ", " "];
129
+ for (const prefix of candidatePrefixes) {
130
+ if (prefixAndTarget === `${prefix}${failedTarget}`) {
131
+ return `${prefix}${failedTarget} failed${suffix}`;
132
+ }
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ function summarizePatchCounts(result: ExecutePatchResult): string {
138
+ return [
139
+ `changed ${result.changedFiles.length} file${result.changedFiles.length === 1 ? "" : "s"}`,
140
+ `created ${result.createdFiles.length}`,
141
+ `deleted ${result.deletedFiles.length}`,
142
+ `moved ${result.movedFiles.length}`,
143
+ ].join(", ");
144
+ }
145
+
146
+ function describeFailedAction(error: ExecutePatchError, cwd: string): string | undefined {
147
+ if (!error.failedAction) {
148
+ return undefined;
149
+ }
150
+ return formatPatchTarget(error.failedAction.path, error.failedAction.type === "update" ? error.failedAction.movePath : undefined, cwd);
38
151
  }
39
152
 
40
153
  export type { ExecutePatchResult } from "../patch/types.ts";
@@ -57,7 +170,11 @@ const renderApplyPatchCallWithOptionalContext: any = (
57
170
  }
58
171
  const cached = context?.toolCallId ? applyPatchRenderStates.get(context.toolCallId) : undefined;
59
172
  const cwd = context?.cwd ?? cached?.cwd;
60
- const text = context?.expanded ? cached?.expanded ?? renderApplyPatchCall(patchText, cwd) : cached?.collapsed ?? formatApplyPatchSummary(patchText, cwd);
173
+ const effectivePatchText = cached?.patchText ?? patchText;
174
+ const baseText = context?.expanded
175
+ ? cached?.expanded ?? renderApplyPatchCall(effectivePatchText, cwd)
176
+ : cached?.collapsed ?? formatApplyPatchSummary(effectivePatchText, cwd);
177
+ const text = cached?.status === "partial_failure" ? renderPartialFailureCall(baseText, theme, cached.failedTarget) : baseText;
61
178
  return new Text(text, 0, 0);
62
179
  };
63
180
 
@@ -67,7 +184,10 @@ export function registerApplyPatchTool(pi: ExtensionAPI): void {
67
184
  label: "apply_patch",
68
185
  description: "Use `apply_patch` to edit files. Send the full patch in `input`.",
69
186
  promptSnippet: "Edit files with a patch.",
70
- promptGuidelines: ["Prefer apply_patch for focused textual edits instead of rewriting whole files."],
187
+ promptGuidelines: [
188
+ "Prefer apply_patch for focused textual edits instead of rewriting whole files.",
189
+ "When one task needs coordinated edits across multiple files, send them in a single apply_patch call when one coherent patch will do.",
190
+ ],
71
191
  parameters: APPLY_PATCH_PARAMETERS,
72
192
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
73
193
  if (signal?.aborted) {
@@ -75,12 +195,34 @@ export function registerApplyPatchTool(pi: ExtensionAPI): void {
75
195
  }
76
196
 
77
197
  const typedParams = parseApplyPatchParams(params);
78
- applyPatchRenderStates.set(toolCallId, {
79
- cwd: ctx.cwd,
80
- collapsed: formatApplyPatchSummary(typedParams.patchText, ctx.cwd),
81
- expanded: renderApplyPatchCall(typedParams.patchText, ctx.cwd),
82
- });
83
- const result = executePatch({ cwd: ctx.cwd, patchText: typedParams.patchText });
198
+ setApplyPatchRenderState(toolCallId, typedParams.patchText, ctx.cwd);
199
+ let result: ExecutePatchResult;
200
+ try {
201
+ result = executePatch({ cwd: ctx.cwd, patchText: typedParams.patchText });
202
+ } catch (error) {
203
+ if (error instanceof ExecutePatchError) {
204
+ const partial = error.hasPartialSuccess();
205
+ const failedTarget = describeFailedAction(error, ctx.cwd);
206
+ const prefix = partial
207
+ ? `apply_patch partially failed after ${summarizePatchCounts(error.result)}`
208
+ : "apply_patch failed";
209
+ const message = failedTarget ? `${prefix} while patching ${failedTarget}: ${error.message}` : `${prefix}: ${error.message}`;
210
+ if (partial) {
211
+ markApplyPatchPartialFailure(toolCallId, failedTarget);
212
+ return {
213
+ content: [{ type: "text", text: message }],
214
+ details: {
215
+ status: "partial_failure",
216
+ result: error.result,
217
+ error: message,
218
+ failedTarget,
219
+ } satisfies ApplyPatchPartialFailureDetails,
220
+ };
221
+ }
222
+ throw new Error(message);
223
+ }
224
+ throw error;
225
+ }
84
226
  const summary = [
85
227
  "Applied patch successfully.",
86
228
  `Changed files: ${result.changedFiles.length}`,
@@ -92,16 +234,23 @@ export function registerApplyPatchTool(pi: ExtensionAPI): void {
92
234
 
93
235
  return {
94
236
  content: [{ type: "text", text: summary }],
95
- details: result,
237
+ details: {
238
+ status: "success",
239
+ result,
240
+ } satisfies ApplyPatchSuccessDetails,
96
241
  };
97
242
  },
98
243
  renderCall: renderApplyPatchCallWithOptionalContext,
99
- renderResult(result, { isPartial }, theme) {
244
+ renderResult(result, { isPartial, expanded }, theme) {
100
245
  if (isPartial) {
101
246
  return new Text(`${theme.fg("dim", "•")} ${theme.bold("Patching")}`, 0, 0);
102
247
  }
103
248
 
104
- if (!isExecutePatchResult(result.details)) {
249
+ if (!isApplyPatchToolDetails(result.details)) {
250
+ return new Container();
251
+ }
252
+
253
+ if (result.details.status === "partial_failure") {
105
254
  return new Container();
106
255
  }
107
256
 
@@ -121,6 +121,7 @@ export function registerExecCommandTool(pi: ExtensionAPI, tracker: ExecCommandTr
121
121
  promptGuidelines: [
122
122
  "Use exec_command for search, listing files, and local text-file reads.",
123
123
  "Prefer rg or rg --files when possible.",
124
+ "For short or non-interactive commands, omit `yield_time_ms` so the default wait can avoid unnecessary follow-up calls.",
124
125
  "Keep tty disabled unless the command truly needs interactive terminal behavior.",
125
126
  ],
126
127
  parameters: EXEC_COMMAND_PARAMETERS,
@@ -65,10 +65,19 @@ export interface ExecSessionManager {
65
65
  shutdown(): void;
66
66
  }
67
67
 
68
+ export interface ExecSessionManagerOptions {
69
+ defaultExecYieldTimeMs?: number;
70
+ defaultWriteYieldTimeMs?: number;
71
+ minNonInteractiveExecYieldTimeMs?: number;
72
+ minEmptyWriteYieldTimeMs?: number;
73
+ }
74
+
68
75
  const DEFAULT_EXEC_YIELD_TIME_MS = 10_000;
69
- const DEFAULT_WRITE_YIELD_TIME_MS = 10_000;
76
+ const DEFAULT_WRITE_YIELD_TIME_MS = 250;
70
77
  const DEFAULT_MAX_OUTPUT_TOKENS = 10_000;
71
78
  const MIN_YIELD_TIME_MS = 250;
79
+ const MIN_NON_INTERACTIVE_EXEC_YIELD_TIME_MS = 5_000;
80
+ const MIN_EMPTY_WRITE_YIELD_TIME_MS = 5_000;
72
81
  const MAX_YIELD_TIME_MS = 30_000;
73
82
  const MAX_COMMAND_HISTORY = 256;
74
83
 
@@ -139,6 +148,32 @@ function clampYieldTime(yieldTimeMs: number | undefined, fallback: number): numb
139
148
  return Math.min(MAX_YIELD_TIME_MS, Math.max(MIN_YIELD_TIME_MS, value));
140
149
  }
141
150
 
151
+ function clampExecYieldTime(
152
+ yieldTimeMs: number | undefined,
153
+ fallback: number,
154
+ isInteractive: boolean,
155
+ minNonInteractiveExecYieldTimeMs: number,
156
+ ): number {
157
+ const value = clampYieldTime(yieldTimeMs, fallback);
158
+ if (isInteractive) {
159
+ return value;
160
+ }
161
+ return Math.min(MAX_YIELD_TIME_MS, Math.max(minNonInteractiveExecYieldTimeMs, value));
162
+ }
163
+
164
+ function clampWriteYieldTime(
165
+ yieldTimeMs: number | undefined,
166
+ fallback: number,
167
+ isEmptyPoll: boolean,
168
+ minEmptyWriteYieldTimeMs: number,
169
+ ): number {
170
+ const value = clampYieldTime(yieldTimeMs, fallback);
171
+ if (!isEmptyPoll) {
172
+ return value;
173
+ }
174
+ return Math.min(MAX_YIELD_TIME_MS, Math.max(minEmptyWriteYieldTimeMs, value));
175
+ }
176
+
142
177
  function maxCharsForTokens(maxOutputTokens = DEFAULT_MAX_OUTPUT_TOKENS): number {
143
178
  return Math.max(256, maxOutputTokens * 4);
144
179
  }
@@ -309,11 +344,21 @@ function registerAbortHandler(signal: AbortSignal | undefined, onAbort: () => vo
309
344
  return () => signal.removeEventListener("abort", abortListener);
310
345
  }
311
346
 
312
- export function createExecSessionManager(): ExecSessionManager {
347
+ export function createExecSessionManager(options: ExecSessionManagerOptions = {}): ExecSessionManager {
313
348
  let nextSessionId = 1;
314
349
  const sessions = new Map<number, ExecSession>();
315
350
  const commandHistory = new Map<number, string>();
316
351
  const exitListeners = new Set<(sessionId: number, command: string) => void>();
352
+ const defaultExecYieldTimeMs = options.defaultExecYieldTimeMs ?? DEFAULT_EXEC_YIELD_TIME_MS;
353
+ const defaultWriteYieldTimeMs = options.defaultWriteYieldTimeMs ?? DEFAULT_WRITE_YIELD_TIME_MS;
354
+ const minNonInteractiveExecYieldTimeMs = Math.min(
355
+ MAX_YIELD_TIME_MS,
356
+ Math.max(MIN_YIELD_TIME_MS, options.minNonInteractiveExecYieldTimeMs ?? MIN_NON_INTERACTIVE_EXEC_YIELD_TIME_MS),
357
+ );
358
+ const minEmptyWriteYieldTimeMs = Math.min(
359
+ MAX_YIELD_TIME_MS,
360
+ Math.max(MIN_YIELD_TIME_MS, options.minEmptyWriteYieldTimeMs ?? MIN_EMPTY_WRITE_YIELD_TIME_MS),
361
+ );
317
362
 
318
363
  function rememberCommand(sessionId: number, command: string): void {
319
364
  commandHistory.set(sessionId, command);
@@ -494,7 +539,10 @@ export function createExecSessionManager(): ExecSessionManager {
494
539
  sessions.set(session.id, session);
495
540
  rememberCommand(session.id, session.command);
496
541
 
497
- const waitedMs = await waitForExitOrTimeout(session, clampYieldTime(input.yield_time_ms, DEFAULT_EXEC_YIELD_TIME_MS));
542
+ const waitedMs = await waitForExitOrTimeout(
543
+ session,
544
+ clampExecYieldTime(input.yield_time_ms, defaultExecYieldTimeMs, session.interactive, minNonInteractiveExecYieldTimeMs),
545
+ );
498
546
  return makeResult(session, waitedMs, input.max_output_tokens);
499
547
  },
500
548
  write: async (input) => {
@@ -512,7 +560,15 @@ export function createExecSessionManager(): ExecSessionManager {
512
560
  }
513
561
  const waitedMs =
514
562
  session.exitCode === undefined
515
- ? await waitForExitOrTimeout(session, clampYieldTime(input.yield_time_ms, DEFAULT_WRITE_YIELD_TIME_MS))
563
+ ? await waitForExitOrTimeout(
564
+ session,
565
+ clampWriteYieldTime(
566
+ input.yield_time_ms,
567
+ defaultWriteYieldTimeMs,
568
+ !input.chars || input.chars.length === 0,
569
+ minEmptyWriteYieldTimeMs,
570
+ ),
571
+ )
516
572
  : 0;
517
573
  return makeResult(session, waitedMs, input.max_output_tokens);
518
574
  },
@@ -110,6 +110,10 @@ export function registerWriteStdinTool(pi: ExtensionAPI, sessions: ExecSessionMa
110
110
  label: "write_stdin",
111
111
  description: "Writes characters to an existing unified exec session and returns recent output.",
112
112
  promptSnippet: "Write to an exec session.",
113
+ promptGuidelines: [
114
+ "Use empty `chars` only to poll a running exec session.",
115
+ "When polling with empty `chars`, wait meaningfully between polls and do not repeatedly poll by reflex.",
116
+ ],
113
117
  parameters: WRITE_STDIN_PARAMETERS,
114
118
  async execute(_toolCallId, params) {
115
119
  const typed = parseWriteStdinParams(params);