bosun 0.36.2 → 0.36.3

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/autofix.mjs CHANGED
@@ -688,6 +688,7 @@ function detectChangedFiles(repoRoot) {
688
688
  cwd: repoRoot,
689
689
  encoding: "utf8",
690
690
  timeout: 10_000,
691
+ stdio: ["pipe", "pipe", "pipe"],
691
692
  });
692
693
  return output
693
694
  .split(/\r?\n/)
@@ -708,6 +709,7 @@ function getChangeSummary(repoRoot, files) {
708
709
  cwd: repoRoot,
709
710
  encoding: "utf8",
710
711
  timeout: 10_000,
712
+ stdio: ["pipe", "pipe", "pipe"],
711
713
  });
712
714
  return diff.trim() || files.join(", ");
713
715
  } catch {
package/codex-shell.mjs CHANGED
@@ -177,6 +177,7 @@ function sanitizeAndTruncatePrompt(text) {
177
177
  return truncated + `\n\n[...prompt truncated — ${removedBytes} bytes removed to stay within API limits]`;
178
178
  }
179
179
  const REPO_ROOT = resolveRepoRoot();
180
+ const DEFAULT_WORKING_DIRECTORY = REPO_ROOT;
180
181
 
181
182
  // ── State ────────────────────────────────────────────────────────────────────
182
183
 
@@ -187,6 +188,7 @@ let activeThreadId = null; // Thread ID for resume
187
188
  let activeTurn = null; // Whether a turn is in-flight
188
189
  let turnCount = 0; // Number of turns in this thread
189
190
  let currentSessionId = null; // Active session identifier
191
+ let activeWorkingDirectory = DEFAULT_WORKING_DIRECTORY; // Session/thread cwd
190
192
  let threadNeedsPriming = false; // True when a fresh thread needs the system prompt on next turn
191
193
  let codexRuntimeCaps = {
192
194
  hasSteeringApi: false,
@@ -200,6 +202,20 @@ function timestamp() {
200
202
  return new Date().toISOString();
201
203
  }
202
204
 
205
+ function normalizeWorkingDirectory(input) {
206
+ const raw = String(input || "").trim();
207
+ if (!raw) return null;
208
+ try {
209
+ return resolve(raw);
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
215
+ function getWorkingDirectory() {
216
+ return normalizeWorkingDirectory(activeWorkingDirectory) || DEFAULT_WORKING_DIRECTORY;
217
+ }
218
+
203
219
  function resolveCodexTransport() {
204
220
  const raw = String(process.env.CODEX_TRANSPORT || "auto")
205
221
  .trim()
@@ -250,13 +266,17 @@ async function loadState() {
250
266
  activeThreadId = data.threadId || null;
251
267
  turnCount = data.turnCount || 0;
252
268
  currentSessionId = data.currentSessionId || null;
269
+ activeWorkingDirectory =
270
+ normalizeWorkingDirectory(data.workingDirectory) ||
271
+ DEFAULT_WORKING_DIRECTORY;
253
272
  console.log(
254
- `[codex-shell] loaded state: threadId=${activeThreadId}, turns=${turnCount}, session=${currentSessionId}`,
273
+ `[codex-shell] loaded state: threadId=${activeThreadId}, turns=${turnCount}, session=${currentSessionId}, cwd=${getWorkingDirectory()}`,
255
274
  );
256
275
  } catch {
257
276
  activeThreadId = null;
258
277
  turnCount = 0;
259
278
  currentSessionId = null;
279
+ activeWorkingDirectory = DEFAULT_WORKING_DIRECTORY;
260
280
  }
261
281
  }
262
282
 
@@ -270,6 +290,7 @@ async function saveState() {
270
290
  threadId: activeThreadId,
271
291
  turnCount,
272
292
  currentSessionId,
293
+ workingDirectory: getWorkingDirectory(),
273
294
  updatedAt: timestamp(),
274
295
  },
275
296
  null,
@@ -311,6 +332,7 @@ async function saveCurrentSession() {
311
332
  await saveSessionData(currentSessionId, {
312
333
  threadId: activeThreadId,
313
334
  turnCount,
335
+ workingDirectory: getWorkingDirectory(),
314
336
  createdAt: (await loadSessionData(currentSessionId))?.createdAt || timestamp(),
315
337
  lastActiveAt: timestamp(),
316
338
  });
@@ -325,12 +347,16 @@ async function loadSession(sessionId) {
325
347
  turnCount = data.turnCount || 0;
326
348
  activeThread = null; // will be re-created/resumed via getThread()
327
349
  currentSessionId = sessionId;
328
- console.log(`[codex-shell] loaded session ${sessionId}: threadId=${activeThreadId}, turns=${turnCount}`);
350
+ activeWorkingDirectory =
351
+ normalizeWorkingDirectory(data.workingDirectory) ||
352
+ DEFAULT_WORKING_DIRECTORY;
353
+ console.log(`[codex-shell] loaded session ${sessionId}: threadId=${activeThreadId}, turns=${turnCount}, cwd=${getWorkingDirectory()}`);
329
354
  } else {
330
355
  activeThread = null;
331
356
  activeThreadId = null;
332
357
  turnCount = 0;
333
358
  currentSessionId = sessionId;
359
+ activeWorkingDirectory = DEFAULT_WORKING_DIRECTORY;
334
360
  console.log(`[codex-shell] created new session ${sessionId}`);
335
361
  }
336
362
  await saveState();
@@ -366,9 +392,8 @@ Key files:
366
392
  AGENTS.md — Repo guide for agents
367
393
  `;
368
394
 
369
- const THREAD_OPTIONS = {
395
+ const THREAD_BASE_OPTIONS = {
370
396
  sandboxMode: process.env.CODEX_SANDBOX || "workspace-write",
371
- workingDirectory: REPO_ROOT,
372
397
  skipGitRepoCheck: true,
373
398
  webSearchMode: "live",
374
399
  approvalPolicy: "never",
@@ -377,6 +402,13 @@ const THREAD_OPTIONS = {
377
402
  // codex-config.mjs ensureFeatureFlags() handles this during setup.
378
403
  };
379
404
 
405
+ function buildThreadOptions() {
406
+ return {
407
+ ...THREAD_BASE_OPTIONS,
408
+ workingDirectory: getWorkingDirectory(),
409
+ };
410
+ }
411
+
380
412
  /**
381
413
  * Get or create a thread.
382
414
  * Uses fresh-thread mode by default to avoid context bloat.
@@ -384,6 +416,7 @@ const THREAD_OPTIONS = {
384
416
  */
385
417
  async function getThread() {
386
418
  if (activeThread) return activeThread;
419
+ const threadOptions = buildThreadOptions();
387
420
 
388
421
  const { env: resolvedEnv } = resolveCodexProfileRuntime(process.env);
389
422
  Object.assign(process.env, resolvedEnv);
@@ -391,8 +424,21 @@ async function getThread() {
391
424
  if (!codexInstance) {
392
425
  const Cls = await loadCodexSdk();
393
426
  if (!Cls) throw new Error("Codex SDK not available");
394
- // Pass feature overrides via --config so they apply even if config.toml
395
- // hasn't been patched by codex-config.mjs yet.
427
+
428
+ // Inject stream resilience settings via --config overrides so they apply
429
+ // even if config.toml hasn't been patched by codex-config.mjs yet.
430
+ // This is the most reliable path for Azure/Foundry deployments where
431
+ // dropped SSE streams ("response.failed") are the dominant failure mode.
432
+ const providerName = resolvedEnv.OPENAI_BASE_URL?.toLowerCase().includes(".openai.azure.com")
433
+ ? "azure"
434
+ : "openai";
435
+ const STREAM_IDLE_TIMEOUT_MS = 3_600_000; // 60 min — matches Azure max stream lifetime
436
+ const streamProviderOverrides = {
437
+ stream_idle_timeout_ms: STREAM_IDLE_TIMEOUT_MS,
438
+ stream_max_retries: 15,
439
+ request_max_retries: 6,
440
+ };
441
+
396
442
  codexInstance = new Cls({
397
443
  config: {
398
444
  features: {
@@ -402,8 +448,13 @@ async function getThread() {
402
448
  undo: true,
403
449
  steer: true,
404
450
  },
451
+ model_providers: {
452
+ [providerName]: streamProviderOverrides,
453
+ },
405
454
  },
406
455
  });
456
+
457
+ console.log(`[codex-shell] created Codex instance (provider=${providerName}, stream_idle_timeout=${STREAM_IDLE_TIMEOUT_MS}ms, stream_max_retries=${streamProviderOverrides.stream_max_retries})`);
407
458
  }
408
459
 
409
460
  const transport = resolveCodexTransport();
@@ -414,7 +465,7 @@ async function getThread() {
414
465
  try {
415
466
  activeThread = codexInstance.resumeThread(
416
467
  activeThreadId,
417
- THREAD_OPTIONS,
468
+ threadOptions,
418
469
  );
419
470
  if (activeThread) {
420
471
  detectThreadCapabilities(activeThread);
@@ -446,16 +497,16 @@ async function getThread() {
446
497
  // the priming turn is STREAMED (runStreamed) instead of blocking (run).
447
498
  // This eliminates the 2-5 minute silent delay the chat UI suffered because
448
499
  // the old `thread.run(SYSTEM_PROMPT)` call produced zero streaming events.
449
- activeThread = codexInstance.startThread(THREAD_OPTIONS);
500
+ activeThread = codexInstance.startThread(threadOptions);
450
501
  detectThreadCapabilities(activeThread);
451
502
  threadNeedsPriming = true;
452
503
 
453
504
  if (activeThread.id) {
454
505
  activeThreadId = activeThread.id;
455
506
  await saveState();
456
- console.log(`[codex-shell] new thread started: ${activeThreadId} (priming deferred to first user turn)`);
507
+ console.log(`[codex-shell] new thread started: ${activeThreadId} (priming deferred to first user turn, cwd=${threadOptions.workingDirectory})`);
457
508
  } else {
458
- console.log("[codex-shell] new thread started (priming deferred to first user turn)");
509
+ console.log(`[codex-shell] new thread started (priming deferred to first user turn, cwd=${threadOptions.workingDirectory})`);
459
510
  }
460
511
 
461
512
  return activeThread;
@@ -627,6 +678,7 @@ export async function execCodexPrompt(userMessage, options = {}) {
627
678
  persistent = false,
628
679
  sessionId = null,
629
680
  mode = null,
681
+ cwd = null,
630
682
  } = options;
631
683
 
632
684
  agentSdk = resolveAgentSdkConfig({ reload: true });
@@ -651,9 +703,13 @@ export async function execCodexPrompt(userMessage, options = {}) {
651
703
 
652
704
  try {
653
705
  const streamSafety = resolveCodexStreamSafety(timeoutMs);
706
+ const requestedWorkingDirectory = normalizeWorkingDirectory(cwd);
707
+
654
708
  if (!persistent) {
655
709
  // Task executor path — keep existing fresh-thread behavior
656
710
  activeThread = null;
711
+ activeWorkingDirectory =
712
+ requestedWorkingDirectory || DEFAULT_WORKING_DIRECTORY;
657
713
  } else if (sessionId && sessionId !== currentSessionId) {
658
714
  // Switching to a different persistent session
659
715
  await loadSession(sessionId);
@@ -669,6 +725,24 @@ export async function execCodexPrompt(userMessage, options = {}) {
669
725
  }
670
726
  // else: persistent && same session && under limit → reuse activeThread
671
727
 
728
+ if (
729
+ requestedWorkingDirectory &&
730
+ requestedWorkingDirectory !== getWorkingDirectory()
731
+ ) {
732
+ activeWorkingDirectory = requestedWorkingDirectory;
733
+ activeThread = null;
734
+ activeThreadId = null;
735
+ turnCount = 0;
736
+ threadNeedsPriming = false;
737
+ await saveState();
738
+ if (persistent && currentSessionId) {
739
+ await saveCurrentSession();
740
+ }
741
+ console.log(
742
+ `[codex-shell] switched working directory to ${requestedWorkingDirectory} for session ${currentSessionId || "(ephemeral)"}`,
743
+ );
744
+ }
745
+
672
746
  // ── Mode detection ───────────────────────────────────────────────────
673
747
  // "ask" mode should be lightweight — no heavy executor framing that
674
748
  // instructs the agent to run commands and read files. The mode is
@@ -960,6 +1034,7 @@ export async function resetThread() {
960
1034
  turnCount = 0;
961
1035
  activeTurn = null;
962
1036
  currentSessionId = null;
1037
+ activeWorkingDirectory = DEFAULT_WORKING_DIRECTORY;
963
1038
  threadNeedsPriming = false;
964
1039
  await saveState();
965
1040
  console.log("[codex-shell] thread reset");
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * git-editor-fix.mjs — Prevent agents from opening interactive editors
4
+ *
5
+ * Problem: Agents inherit user's git config which uses VSCode (`code --wait`)
6
+ * Result: Git operations block waiting for editor, freezing agents
7
+ *
8
+ * Solution: Set GIT_EDITOR=true (or GIT_EDITOR=:) for non-interactive mode
9
+ *
10
+ * This script ensures all agent workspaces have non-blocking git config.
11
+ * Covers: main repo, tmpclaude-* workspaces, git worktrees (ve/*),
12
+ * and VK task worktrees under $TEMP/vibe-kanban/worktrees/.
13
+ */
14
+
15
+ import { execSync } from "child_process";
16
+ import { existsSync, readdirSync } from "fs";
17
+ import { resolve, basename } from "path";
18
+ import { tmpdir } from "os";
19
+ import { fileURLToPath } from "url";
20
+ import { resolveRepoRoot } from "./repo-root.mjs";
21
+
22
+ const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
23
+ const REPO_ROOT = resolveRepoRoot();
24
+
25
+ /**
26
+ * Configure git to never open interactive editors
27
+ * @param {string} workspacePath - Path to workspace directory
28
+ * @returns {boolean} true if configured successfully
29
+ */
30
+ function configureNonInteractiveGit(workspacePath) {
31
+ const gitDir = resolve(workspacePath, ".git");
32
+
33
+ // .git can be a file (worktree link) or a directory — both are valid
34
+ if (!existsSync(gitDir)) {
35
+ console.warn(`[git-editor-fix] No .git entry at ${workspacePath}`);
36
+ return false;
37
+ }
38
+
39
+ try {
40
+ // Set local git config for this workspace
41
+ // Use ':' (colon) as no-op editor — POSIX standard that always succeeds
42
+ execSync("git config --local core.editor :", {
43
+ cwd: workspacePath,
44
+ stdio: "pipe",
45
+ });
46
+
47
+ // Also disable merge commit editor prompts
48
+ execSync("git config --local merge.commit.autoEdit no", {
49
+ cwd: workspacePath,
50
+ stdio: "pipe",
51
+ });
52
+
53
+ console.log(
54
+ `[git-editor-fix] ✓ Configured ${workspacePath} for non-interactive git`,
55
+ );
56
+ return true;
57
+ } catch (err) {
58
+ console.error(
59
+ `[git-editor-fix] Failed to configure ${workspacePath}:`,
60
+ err.message,
61
+ );
62
+ return false;
63
+ }
64
+ }
65
+
66
+ // ── Workspace discovery helpers ──────────────────────────────────────────────
67
+
68
+ /**
69
+ * Collect tmpclaude-* directories under REPO_ROOT
70
+ * @returns {string[]}
71
+ */
72
+ function findTmpclaudeWorkspaces() {
73
+ /** @type {string[]} */
74
+ const results = [];
75
+ try {
76
+ const entries = readdirSync(REPO_ROOT, { withFileTypes: true });
77
+ for (const entry of entries) {
78
+ if (entry.isDirectory() && entry.name.startsWith("tmpclaude-")) {
79
+ results.push(resolve(REPO_ROOT, entry.name));
80
+ }
81
+ }
82
+ } catch (err) {
83
+ console.error(
84
+ "[git-editor-fix] Failed to scan tmpclaude workspaces:",
85
+ err.message,
86
+ );
87
+ }
88
+ return results;
89
+ }
90
+
91
+ /**
92
+ * Parse `git worktree list --porcelain` and return paths of ve/* worktrees
93
+ * @returns {string[]}
94
+ */
95
+ function findGitWorktrees() {
96
+ /** @type {string[]} */
97
+ const results = [];
98
+ try {
99
+ const raw = execSync("git worktree list --porcelain", {
100
+ cwd: REPO_ROOT,
101
+ stdio: ["pipe", "pipe", "pipe"],
102
+ encoding: "utf-8",
103
+ });
104
+
105
+ // Porcelain output has blocks separated by blank lines.
106
+ // Each block starts with "worktree <path>".
107
+ // We also look for "branch refs/heads/ve/..." to identify VK worktrees,
108
+ // but we include ALL worktrees — they all need the fix.
109
+ for (const line of raw.split("\n")) {
110
+ const match = line.match(/^worktree\s+(.+)$/);
111
+ if (match) {
112
+ const wtPath = match[1].trim();
113
+ // Skip the bare repo entry if present
114
+ if (existsSync(resolve(wtPath, ".git"))) {
115
+ results.push(wtPath);
116
+ }
117
+ }
118
+ }
119
+ } catch (err) {
120
+ // git worktree list fails if the repo has no worktrees — that's fine
121
+ if (!String(err.message).includes("is not a git repository")) {
122
+ console.warn(
123
+ "[git-editor-fix] Could not enumerate git worktrees:",
124
+ err.message,
125
+ );
126
+ }
127
+ }
128
+ return results;
129
+ }
130
+
131
+ /**
132
+ * Scan $TEMP/vibe-kanban/worktrees/ for VK task worktree directories
133
+ * @returns {string[]}
134
+ */
135
+ function findVKWorktrees() {
136
+ /** @type {string[]} */
137
+ const results = [];
138
+ const vkBase = resolve(tmpdir(), "vibe-kanban", "worktrees");
139
+
140
+ if (!existsSync(vkBase)) {
141
+ return results;
142
+ }
143
+
144
+ try {
145
+ const entries = readdirSync(vkBase, { withFileTypes: true });
146
+ for (const entry of entries) {
147
+ if (entry.isDirectory()) {
148
+ const candidate = resolve(vkBase, entry.name);
149
+ if (existsSync(resolve(candidate, ".git"))) {
150
+ results.push(candidate);
151
+ }
152
+ }
153
+ }
154
+ } catch (err) {
155
+ console.error("[git-editor-fix] Failed to scan VK worktrees:", err.message);
156
+ }
157
+ return results;
158
+ }
159
+
160
+ // ── Main functions ───────────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * Scan for all agent workspaces and fix git config.
164
+ * Sources:
165
+ * 1. Main repo root (REPO_ROOT)
166
+ * 2. tmpclaude-* directories
167
+ * 3. git worktrees (parsed from `git worktree list --porcelain`)
168
+ * 4. VK task worktrees under $TEMP/vibe-kanban/worktrees/
169
+ *
170
+ * Paths are deduplicated before configuration.
171
+ */
172
+ function fixAllWorkspaces() {
173
+ console.log("[git-editor-fix] Scanning for agent workspaces...");
174
+
175
+ /** @type {Set<string>} */
176
+ const seen = new Set();
177
+
178
+ /** @param {string} p */
179
+ const add = (p) => {
180
+ const normalized = resolve(p);
181
+ seen.add(normalized);
182
+ };
183
+
184
+ // 1. Main repo root
185
+ add(REPO_ROOT);
186
+
187
+ // 2. tmpclaude-* directories
188
+ for (const ws of findTmpclaudeWorkspaces()) {
189
+ add(ws);
190
+ }
191
+
192
+ // 3. Git worktrees (includes ve/* branches)
193
+ for (const ws of findGitWorktrees()) {
194
+ add(ws);
195
+ }
196
+
197
+ // 4. VK worktrees under $TEMP
198
+ for (const ws of findVKWorktrees()) {
199
+ add(ws);
200
+ }
201
+
202
+ const workspaces = [...seen];
203
+ console.log(
204
+ `[git-editor-fix] Found ${workspaces.length} workspace(s) to configure`,
205
+ );
206
+
207
+ let fixed = 0;
208
+ for (const ws of workspaces) {
209
+ if (configureNonInteractiveGit(ws)) {
210
+ fixed++;
211
+ }
212
+ }
213
+
214
+ console.log(
215
+ `[git-editor-fix] ✓ Fixed ${fixed}/${workspaces.length} workspaces`,
216
+ );
217
+ }
218
+
219
+ /**
220
+ * Convenience wrapper: configure the main repo and all discoverable worktrees
221
+ * in a single call. Suitable for use from other modules.
222
+ * @returns {{ fixed: number, total: number }}
223
+ */
224
+ function configureRepoAndWorktrees() {
225
+ console.log("[git-editor-fix] Configuring repo and all worktrees...");
226
+
227
+ /** @type {Set<string>} */
228
+ const seen = new Set();
229
+
230
+ const add = (/** @type {string} */ p) => seen.add(resolve(p));
231
+
232
+ add(REPO_ROOT);
233
+ findTmpclaudeWorkspaces().forEach(add);
234
+ findGitWorktrees().forEach(add);
235
+ findVKWorktrees().forEach(add);
236
+
237
+ const workspaces = [...seen];
238
+ let fixed = 0;
239
+ for (const ws of workspaces) {
240
+ if (configureNonInteractiveGit(ws)) {
241
+ fixed++;
242
+ }
243
+ }
244
+
245
+ console.log(
246
+ `[git-editor-fix] ✓ Configured ${fixed}/${workspaces.length} workspace(s)`,
247
+ );
248
+ return { fixed, total: workspaces.length };
249
+ }
250
+
251
+ // ── CLI Entry Point ──────────────────────────────────────────────────────────
252
+
253
+ const isMainModule = () => {
254
+ try {
255
+ const modulePath = fileURLToPath(import.meta.url);
256
+ return process.argv[1] === modulePath;
257
+ } catch {
258
+ return false;
259
+ }
260
+ };
261
+
262
+ if (isMainModule()) {
263
+ fixAllWorkspaces();
264
+ }
265
+
266
+ export {
267
+ configureNonInteractiveGit,
268
+ fixAllWorkspaces,
269
+ configureRepoAndWorktrees,
270
+ findGitWorktrees,
271
+ findVKWorktrees,
272
+ findTmpclaudeWorkspaces,
273
+ };