@bastani/atomic 0.6.6-0 → 0.6.6

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.
@@ -62,11 +62,13 @@ import {
62
62
  HeadlessClaudeSessionWrapper,
63
63
  } from "../providers/claude.ts";
64
64
  import { withHeadlessOpencodeEnv } from "../providers/opencode.ts";
65
+ import { resolveCopilotCliPath } from "../providers/copilot.ts";
65
66
  import { OrchestratorPanel } from "./panel.tsx";
66
67
  import { GraphFrontierTracker } from "./graph-inference.ts";
67
68
  import { buildSnapshot, writeSnapshot } from "./status-writer.ts";
68
69
  import { errorMessage } from "../errors.ts";
69
70
  import { createPainter } from "../../theme/colors.ts";
71
+ import { atomicTempEnv } from "../../lib/atomic-temp.ts";
70
72
 
71
73
  /** Maximum time (ms) for the SDK probe to succeed after port is discovered. */
72
74
  export const SERVER_PROBE_TIMEOUT_MS = 60_000;
@@ -287,29 +289,37 @@ export function buildPaneCommand(
287
289
  envVars: defaultEnvVars,
288
290
  } = AGENT_CLI[agent];
289
291
  const chatFlags = overrides.chatFlags ?? defaultFlags;
292
+ const claudeTempEnv = agent === "claude" ? atomicTempEnv() : {};
290
293
  const envVars = overrides.envVars
291
294
  ? { ...defaultEnvVars, ...overrides.envVars }
292
295
  : defaultEnvVars;
296
+ const mergedEnvVars = { ...envVars, ...claudeTempEnv, ...overrides.envVars };
293
297
 
294
298
  const resolvedCmd = quotePathIfNeeded(resolveCliBinary(cmd));
295
299
 
296
300
  switch (agent) {
297
- case "copilot":
301
+ case "copilot": {
302
+ // Prefer the copilot binary resolved via resolveCopilotCliPath so that
303
+ // COPILOT_CLI_PATH (set by applyContainerEnvDefaults in Bun-without-node
304
+ // environments) is honoured in the tmux pane command, keeping the pane
305
+ // binary consistent with the SDK subprocess binary.
306
+ const copilotBin = resolveCopilotCliPath() ?? resolveCliBinary(cmd);
298
307
  return {
299
308
  command: [
300
- resolvedCmd,
309
+ quotePathIfNeeded(copilotBin),
301
310
  "--ui-server",
302
311
  "--port",
303
312
  "0",
304
313
  ...chatFlags,
305
314
  ...extraChatFlags,
306
315
  ].join(" "),
307
- envVars,
316
+ envVars: mergedEnvVars,
308
317
  };
318
+ }
309
319
  case "opencode":
310
320
  return {
311
321
  command: [resolvedCmd, "--port", "0", ...chatFlags].join(" "),
312
- envVars,
322
+ envVars: mergedEnvVars,
313
323
  };
314
324
  case "claude": {
315
325
  // Claude is started via createClaudeSession() in the workflow's run().
@@ -323,7 +333,7 @@ export function buildPaneCommand(
323
333
  : resolveCliBinary(shellCandidate);
324
334
  return {
325
335
  command: quotePathIfNeeded(resolvedShell),
326
- envVars,
336
+ envVars: mergedEnvVars,
327
337
  };
328
338
  }
329
339
  default:
@@ -494,6 +504,7 @@ export async function executeWorkflow(
494
504
  const launcherExt = isWin ? "ps1" : "sh";
495
505
  const launcherPath = join(sessionsBaseDir, `orchestrator.${launcherExt}`);
496
506
  const logPath = join(sessionsBaseDir, "orchestrator.log");
507
+ const launcherEnvVars = agent === "claude" ? atomicTempEnv() : {};
497
508
 
498
509
  // Inputs are passed through as base64-encoded JSON so long multiline
499
510
  // text values survive shell quoting without any further escaping.
@@ -515,6 +526,9 @@ export async function executeWorkflow(
515
526
  const launcherScript = isWin
516
527
  ? [
517
528
  `Set-Location "${escPwsh(projectRoot)}"`,
529
+ ...Object.entries(launcherEnvVars).map(
530
+ ([key, value]) => `$env:${key} = "${escPwsh(value)}"`,
531
+ ),
518
532
  `$env:ATOMIC_WF_ID = "${escPwsh(workflowRunId)}"`,
519
533
  `$env:ATOMIC_WF_TMUX = "${escPwsh(tmuxSessionName)}"`,
520
534
  `$env:ATOMIC_WF_AGENT = "${escPwsh(agent)}"`,
@@ -524,6 +538,9 @@ export async function executeWorkflow(
524
538
  : [
525
539
  "#!/bin/bash",
526
540
  `cd "${escBash(projectRoot)}"`,
541
+ ...Object.entries(launcherEnvVars).map(
542
+ ([key, value]) => `export ${key}="${escBash(value)}"`,
543
+ ),
527
544
  `export ATOMIC_WF_ID="${escBash(workflowRunId)}"`,
528
545
  `export ATOMIC_WF_TMUX="${escBash(tmuxSessionName)}"`,
529
546
  `export ATOMIC_WF_AGENT="${escBash(agent)}"`,
@@ -536,7 +553,7 @@ export async function executeWorkflow(
536
553
  const shellCmd = isWin
537
554
  ? `pwsh -NoProfile -File "${escPwsh(launcherPath)}"`
538
555
  : `bash "${escBash(launcherPath)}"`;
539
- tmux.createSession(tmuxSessionName, shellCmd, "orchestrator");
556
+ tmux.createSession(tmuxSessionName, shellCmd, "orchestrator", undefined, launcherEnvVars);
540
557
  tmux.setSessionEnv(tmuxSessionName, "ATOMIC_AGENT", agent);
541
558
 
542
559
  if (detach) {
@@ -1322,7 +1339,7 @@ async function initProviderClientAndSession<A extends AgentType>(
1322
1339
  switch (agent) {
1323
1340
  case "copilot": {
1324
1341
  const { CopilotClient, approveAll } = await import("@github/copilot-sdk");
1325
- const { copilotSubprocessEnv, mergeCopilotSystemMessage } =
1342
+ const { copilotSdkLaunchOptions, mergeCopilotSystemMessage } =
1326
1343
  await import("../providers/copilot.ts");
1327
1344
  const { resolveAdditionalInstructionsContent } =
1328
1345
  await import("../../services/config/additional-instructions.ts");
@@ -1331,7 +1348,7 @@ async function initProviderClientAndSession<A extends AgentType>(
1331
1348
  // Headless: let the SDK spawn its own CLI process (no cliUrl).
1332
1349
  // Non-headless: connect to the CLI server running in a tmux pane.
1333
1350
  // `env` is only meaningful in the headless path — the SDK ignores
1334
- // it when `cliUrl` is set — but layering in `copilotSubprocessEnv`
1351
+ // it when `cliUrl` is set — but layering in `copilotSdkLaunchOptions`
1335
1352
  // when the caller didn't supply their own env keeps the
1336
1353
  // SQLite `ExperimentalWarning` from leaking through the SDK's
1337
1354
  // `[CLI subprocess]` stderr forwarder.
@@ -1339,7 +1356,7 @@ async function initProviderClientAndSession<A extends AgentType>(
1339
1356
  let client: InstanceType<typeof CopilotClient>;
1340
1357
  if (headless) {
1341
1358
  client = new CopilotClient({
1342
- env: copilotSubprocessEnv(),
1359
+ ...copilotSdkLaunchOptions(),
1343
1360
  ...copilotClientOpts,
1344
1361
  });
1345
1362
  } else {
@@ -9,8 +9,8 @@
9
9
  import { join } from "node:path";
10
10
  import { requiredMuxBinaryCandidatesForPlatform } from "../../lib/spawn.ts";
11
11
  import { writeFileSync, unlinkSync } from "node:fs";
12
- import { tmpdir } from "node:os";
13
12
  import type { Subprocess } from "bun";
13
+ import { atomicTempPath } from "../../lib/atomic-temp.ts";
14
14
 
15
15
  // ---------------------------------------------------------------------------
16
16
  // Constants
@@ -353,7 +353,11 @@ export function sendLiteralText(paneId: string, text: string): void {
353
353
  */
354
354
  export function sendViaPasteBuffer(paneId: string, text: string): void {
355
355
  const normalized = text.replace(/[\r\n]+/g, " ");
356
- const tmp = join(tmpdir(), `atomic-paste-${process.pid}-${Date.now()}.txt`);
356
+ const tmp = atomicTempPath(
357
+ "atomic-paste",
358
+ ".txt",
359
+ `${process.pid}-${Date.now()}`,
360
+ );
357
361
 
358
362
  writeFileSync(tmp, normalized, "utf-8");
359
363
  try {
@@ -38,7 +38,13 @@ let copilotGetAuthStatus = mock<() => Promise<CopilotAuthStatus>>(async () => ({
38
38
  login: "octocat",
39
39
  }));
40
40
 
41
+ // Captures the options passed to `new CopilotClient(...)` on each call.
42
+ let lastCopilotConstructorOptions: unknown = undefined;
43
+
41
44
  class FakeCopilotClient {
45
+ constructor(options: unknown) {
46
+ lastCopilotConstructorOptions = options;
47
+ }
42
48
  async start(): Promise<void> {
43
49
  await copilotStart();
44
50
  }
@@ -114,6 +120,7 @@ afterAll(() => {
114
120
  const { checkAgentAuth, printAuthError } = await import("./auth.ts");
115
121
 
116
122
  beforeEach(() => {
123
+ lastCopilotConstructorOptions = undefined;
117
124
  copilotStart.mockClear();
118
125
  copilotStart.mockImplementation(async () => {});
119
126
  copilotStop.mockClear();
@@ -173,6 +180,52 @@ describe("checkAgentAuth(copilot)", () => {
173
180
  // The stop failure must not shadow the probe result.
174
181
  expect(result.detail).not.toContain("stop crashed");
175
182
  });
183
+
184
+ test("constructs CopilotClient with COPILOT_CLI_PATH as cliPath and NODE_NO_WARNINGS=1 in env", async () => {
185
+ const origCliPath = process.env["COPILOT_CLI_PATH"];
186
+ process.env["COPILOT_CLI_PATH"] = "/explicit/bin/copilot";
187
+ try {
188
+ await checkAgentAuth("copilot");
189
+ const opts = lastCopilotConstructorOptions as {
190
+ cliPath?: string;
191
+ env?: Record<string, string | undefined>;
192
+ };
193
+ expect(opts).toBeDefined();
194
+ expect(opts.cliPath).toBe("/explicit/bin/copilot");
195
+ expect(opts.env?.["NODE_NO_WARNINGS"]).toBe("1");
196
+ } finally {
197
+ if (origCliPath === undefined) delete process.env["COPILOT_CLI_PATH"];
198
+ else process.env["COPILOT_CLI_PATH"] = origCliPath;
199
+ }
200
+ });
201
+
202
+ test("constructs CopilotClient via centralized launch options — env.NODE_NO_WARNINGS=1 present even when COPILOT_CLI_PATH is unset", async () => {
203
+ // When COPILOT_CLI_PATH is absent the auth probe delegates entirely to
204
+ // copilotSdkLaunchOptions(), which always injects NODE_NO_WARNINGS=1
205
+ // via copilotSubprocessEnv(). No cliPath should appear in the options
206
+ // because resolveCopilotCliPath() returns undefined when the env var is
207
+ // not set and no standalone binary is found on PATH.
208
+ const origCliPath = process.env["COPILOT_CLI_PATH"];
209
+ delete process.env["COPILOT_CLI_PATH"];
210
+ try {
211
+ await checkAgentAuth("copilot");
212
+ const opts = lastCopilotConstructorOptions as {
213
+ cliPath?: string;
214
+ env?: Record<string, string | undefined>;
215
+ };
216
+ expect(opts).toBeDefined();
217
+ // Centralized env must always include NODE_NO_WARNINGS regardless of cliPath.
218
+ expect(opts.env?.["NODE_NO_WARNINGS"]).toBe("1");
219
+ // cliPath must not be injected from a stale or undefined resolution.
220
+ // (It may be defined if a copilot binary happens to be on PATH in the
221
+ // test environment, but it must never be "/explicit/bin/copilot" which
222
+ // is only set by the sibling test above.)
223
+ expect(opts.cliPath).not.toBe("/explicit/bin/copilot");
224
+ } finally {
225
+ if (origCliPath === undefined) delete process.env["COPILOT_CLI_PATH"];
226
+ else process.env["COPILOT_CLI_PATH"] = origCliPath;
227
+ }
228
+ });
176
229
  });
177
230
 
178
231
  describe("checkAgentAuth(claude)", () => {
@@ -17,7 +17,8 @@
17
17
 
18
18
  import type { AgentKey } from "../config/index.ts";
19
19
  import { COLORS } from "../../theme/colors.ts";
20
- import { copilotSubprocessEnv } from "../../sdk/providers/copilot.ts";
20
+ import { copilotSdkLaunchOptions } from "../../sdk/providers/copilot.ts";
21
+ import { withAtomicTempEnv } from "../../lib/atomic-temp.ts";
21
22
 
22
23
  export interface AuthCheckResult {
23
24
  /** True when the SDK reports the user is authenticated. */
@@ -72,7 +73,7 @@ const AUTH_PROMPTS: Record<AgentKey, { name: string; loginHint: string }> = {
72
73
 
73
74
  async function checkCopilotAuth(): Promise<AuthCheckResult> {
74
75
  const { CopilotClient } = await import("@github/copilot-sdk");
75
- const client = new CopilotClient({ env: copilotSubprocessEnv() });
76
+ const client = new CopilotClient(copilotSdkLaunchOptions());
76
77
  try {
77
78
  await client.start();
78
79
  const status = await client.getAuthStatus();
@@ -105,33 +106,35 @@ async function checkClaudeAuth(): Promise<AuthCheckResult> {
105
106
  // actually delivered.
106
107
  async function* emptyStream(): AsyncGenerator<never, void, void> {}
107
108
 
108
- const q = query({
109
- prompt: emptyStream(),
110
- options: {
111
- pathToClaudeCodeExecutable: resolveHeadlessClaudeBin(),
112
- },
113
- });
109
+ return await withAtomicTempEnv(async () => {
110
+ const q = query({
111
+ prompt: emptyStream(),
112
+ options: {
113
+ pathToClaudeCodeExecutable: resolveHeadlessClaudeBin(),
114
+ },
115
+ });
114
116
 
115
- try {
116
- const init = await q.initializationResult();
117
- const account = init.account ?? {};
118
- const loggedIn = Boolean(
119
- account.email || account.tokenSource || account.apiKeySource,
120
- );
121
- return {
122
- loggedIn,
123
- identity: account.email,
124
- };
125
- } catch (err) {
126
- return {
127
- loggedIn: false,
128
- detail: err instanceof Error ? err.message : String(err),
129
- };
130
- } finally {
131
117
  try {
132
- q.close();
133
- } catch {
134
- // Best effort — the subprocess is torn down on process exit anyway.
118
+ const init = await q.initializationResult();
119
+ const account = init.account ?? {};
120
+ const loggedIn = Boolean(
121
+ account.email || account.tokenSource || account.apiKeySource,
122
+ );
123
+ return {
124
+ loggedIn,
125
+ identity: account.email,
126
+ };
127
+ } catch (err) {
128
+ return {
129
+ loggedIn: false,
130
+ detail: err instanceof Error ? err.message : String(err),
131
+ };
132
+ } finally {
133
+ try {
134
+ q.close();
135
+ } catch {
136
+ // Best effort — the subprocess is torn down on process exit anyway.
137
+ }
135
138
  }
136
- }
139
+ });
137
140
  }
@@ -24,7 +24,7 @@ export function isCommandInstalled(cmd: string): boolean {
24
24
  * @returns The absolute path to the command, or null if not found
25
25
  */
26
26
  export function getCommandPath(cmd: string): string | null {
27
- return Bun.which(cmd);
27
+ return Bun.which(cmd, { PATH: process.env["PATH"] ?? "" });
28
28
  }
29
29
 
30
30
  /**