@adecore/agents 0.29.1 → 0.29.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.
@@ -1,4 +1,3 @@
1
- import { homedir } from 'node:os';
2
1
  import { join } from 'node:path';
3
2
  import { ChatAttachmentUploadsSchema, generatedImageAttachment, HOST_TASK_ORIGIN } from '@adecore/agent-contracts';
4
3
  import { ClientSinks } from '../client-sinks.js';
@@ -15,6 +14,7 @@ import { ChatError } from './errors.js';
15
14
  import { readableAssistantText } from './readable-text.js';
16
15
  import { SubagentReader } from './subagent-reader.js';
17
16
  import { ChatUiQueries } from './ui-queries.js';
17
+ import { homeFolder } from '../home-folder.js';
18
18
  function withoutHidden(info) {
19
19
  const { hidden: _hidden, ...rest } = info;
20
20
  return rest;
@@ -251,7 +251,7 @@ export class ChatCore {
251
251
  const provider = this.providers.get(kind);
252
252
  const catalog = provider.catalog;
253
253
  const selection = catalog.normalize(stored?.info.selection ?? this.hooks.openingSelection?.(payload.chatId, kind) ?? payload.selection);
254
- const cwd = stored?.info.cwd ?? payload.cwd ?? this.env.HOME ?? homedir();
254
+ const cwd = stored?.info.cwd ?? payload.cwd ?? homeFolder(this.env);
255
255
  await this.hooks.admit?.(payload.chatId, cwd);
256
256
  // A thread on disk keeps its account too. One it lost still opens, and says why on its next turn.
257
257
  // A new one without an account takes the person's pick for this CLI, which is refused rather than replaced when it went.
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { Readable } from 'node:stream';
3
+ import { spawnTarget } from '../windows-command.js';
3
4
  // How long an exit waits for the rest of stdout; a process the CLI started may hold the pipe open.
4
5
  const STDOUT_DRAIN_MS = 250;
5
6
  export function spawnChatProcess(options) {
@@ -7,10 +8,12 @@ export function spawnChatProcess(options) {
7
8
  if (command === undefined) {
8
9
  throw new Error('No command to start');
9
10
  }
10
- const child = spawn(command, args, {
11
+ const target = spawnTarget(command, args, { env: options.env });
12
+ const child = spawn(target.file, target.args, {
11
13
  cwd: options.cwd,
12
14
  env: options.env,
13
15
  stdio: ['pipe', 'pipe', 'pipe'],
16
+ windowsVerbatimArguments: target.windowsVerbatimArguments ?? false,
14
17
  // A group of its own, so ending the chat also ends what its agent started (a dev server, a watcher).
15
18
  // On Windows that would open a console window of its own instead; `taskkill` reaches the tree there.
16
19
  detached: process.platform !== 'win32',
@@ -104,6 +104,7 @@ export declare class ChatSession {
104
104
  private staleResults;
105
105
  private spokenTurnId;
106
106
  private titleTimer;
107
+ private readonly checkpointWork;
107
108
  private naming;
108
109
  private frozen;
109
110
  private readonly subagentOwners;
@@ -232,6 +233,7 @@ export declare class ChatSession {
232
233
  private settleAgentTurn;
233
234
  private checkpoint;
234
235
  private settleCheckpoint;
236
+ private trackCheckpoint;
235
237
  private contextNote;
236
238
  private submit;
237
239
  private acceptPrompt;
@@ -71,6 +71,8 @@ export class ChatSession {
71
71
  // The last turn the main agent spoke in; a turn opened only for a subagent's request gets no `result`.
72
72
  spokenTurnId = null;
73
73
  titleTimer = null;
74
+ /* Checkpoints still asking git, which `dispose` waits for: the host may remove the folder next. */
75
+ checkpointWork = new Set();
74
76
  // A name is asked for once per chat this host holds; the turn count keeps it once across restarts.
75
77
  naming = false;
76
78
  // Set while the host goes down: a CLI dying with it must not end the turn a restart takes up again.
@@ -1126,7 +1128,7 @@ export class ChatSession {
1126
1128
  this.frozen = true;
1127
1129
  this.backend?.stop();
1128
1130
  }
1129
- /* Ends the CLI and everything it started; settles once it exited or was sent the SIGKILL. */
1131
+ /* Ends the CLI and everything it started; settles once it exited or was sent the SIGKILL and no checkpoint asks git any more. */
1130
1132
  dispose() {
1131
1133
  this.uiReplies.clear();
1132
1134
  this.clearStartWarning();
@@ -1138,7 +1140,7 @@ export class ChatSession {
1138
1140
  const ended = this.backend?.dispose() ?? Promise.resolve();
1139
1141
  this.backend = null;
1140
1142
  this.starting = null;
1141
- return ended;
1143
+ return Promise.all([ended, ...this.checkpointWork]).then(() => undefined);
1142
1144
  }
1143
1145
  /* What a turn changed against its checkpoint: the stored answer, or one taken now while it runs. */
1144
1146
  async turnDiff(turnId) {
@@ -1412,15 +1414,13 @@ export class ChatSession {
1412
1414
  return Promise.resolve();
1413
1415
  }
1414
1416
  const cwd = this.thread.info.cwd;
1415
- return Promise.all([checkpoints.take(cwd), checkpoints.head?.(cwd) ?? null])
1416
- .then(([tree, head]) => {
1417
+ return this.trackCheckpoint(Promise.all([checkpoints.take(cwd), checkpoints.head?.(cwd) ?? null]).then(([tree, head]) => {
1417
1418
  const turn = this.thread.get(turnId);
1418
1419
  if (tree === null || turn?.kind !== 'turn' || turn.state !== 'running' || this.info.activeTurnId !== turnId) {
1419
1420
  return;
1420
1421
  }
1421
1422
  this.emit([this.thread.upsert({ ...turn, checkpoint: tree, ...(head === null ? {} : { checkpointHead: head }) })]);
1422
- })
1423
- .catch(() => undefined);
1423
+ }));
1424
1424
  }
1425
1425
  /* The diff of a turn that just ended, so a reload shows it without asking git again, and the tree a fork after it starts from. */
1426
1426
  settleCheckpoint(turnId) {
@@ -1430,8 +1430,7 @@ export class ChatSession {
1430
1430
  }
1431
1431
  const { checkpoints } = this.options;
1432
1432
  const cwd = this.thread.info.cwd;
1433
- void Promise.all([checkpoints.settle(cwd, turn.checkpoint), checkpoints.head?.(cwd) ?? null])
1434
- .then(([answer, head]) => {
1433
+ void this.trackCheckpoint(Promise.all([checkpoints.settle(cwd, turn.checkpoint), checkpoints.head?.(cwd) ?? null]).then(([answer, head]) => {
1435
1434
  const settled = this.thread.get(turnId);
1436
1435
  if (answer === null || settled?.kind !== 'turn') {
1437
1436
  return;
@@ -1445,8 +1444,13 @@ export class ChatSession {
1445
1444
  })
1446
1445
  ]);
1447
1446
  this.options.persist();
1448
- })
1449
- .catch(() => undefined);
1447
+ }));
1448
+ }
1449
+ /* Keeps the work in `checkpointWork` until it settled; a failure leaves the turn without a checkpoint. */
1450
+ trackCheckpoint(work) {
1451
+ const tracked = work.catch(() => undefined).finally(() => this.checkpointWork.delete(tracked));
1452
+ this.checkpointWork.add(tracked);
1453
+ return tracked;
1450
1454
  }
1451
1455
  /*
1452
1456
  * What the agent has to hear before this prompt: what the chat kept for it and what the host says.
@@ -1,4 +1,4 @@
1
- import { dirname } from 'node:path';
1
+ import { basename, dirname } from 'node:path';
2
2
  import { workflowAgentIdOf } from '@adecore/agent-contracts';
3
3
  import { SYSTEM_WATCH } from '../watch-seam.js';
4
4
  import { findSubagentsDir, findWorkflowAgent, readSubagentMetas, TranscriptProjection } from './claude-transcript.js';
@@ -340,7 +340,7 @@ export class SubagentReader {
340
340
  for (const key of this.dirWatches.get(dir)?.keys ?? []) {
341
341
  const path = this.claudeFiles.get(key)?.path;
342
342
  // A platform that names no file could mean any of them.
343
- if (filename === null || (path !== undefined && path.endsWith(`/${filename}`))) {
343
+ if (filename === null || (path !== undefined && basename(path) === filename)) {
344
344
  this.markChanged(key);
345
345
  }
346
346
  }
@@ -0,0 +1 @@
1
+ export declare function homeFolder(env: Record<string, string | undefined>, platform?: NodeJS.Platform): string;
@@ -0,0 +1,8 @@
1
+ import { homedir } from 'node:os';
2
+ /*
3
+ * The home folder the CLIs read their config from. On Windows they take it from USERPROFILE, as Node
4
+ * does; a HOME there is often Git Bash's `/c/Users/...`, which no native program can open.
5
+ */
6
+ export function homeFolder(env, platform = process.platform) {
7
+ return (platform === 'win32' ? env.USERPROFILE : env.HOME) || homedir();
8
+ }
@@ -1,7 +1,7 @@
1
- import { homedir } from 'node:os';
2
1
  import { isAbsolute, join, resolve } from 'node:path';
3
2
  import { AgentKindSchema, ProviderAccountSchema } from '@adecore/agent-contracts';
4
3
  import { DEFAULT_ACCOUNTS_HOST, variablesProblem } from './variables.js';
4
+ import { homeFolder } from '../../home-folder.js';
5
5
  const KNOWN_KINDS = AgentKindSchema.options;
6
6
  export function isKnownKind(kind) {
7
7
  return KNOWN_KINDS.includes(kind);
@@ -11,11 +11,11 @@ export function isDefaultAccount(id, account) {
11
11
  return id === account.kind;
12
12
  }
13
13
  export function expandHome(path, env) {
14
- const home = env.HOME ?? homedir();
14
+ const home = homeFolder(env);
15
15
  if (path === '~') {
16
16
  return home;
17
17
  }
18
- if (path.startsWith('~/')) {
18
+ if (path.startsWith('~/') || (process.platform === 'win32' && path.startsWith('~\\'))) {
19
19
  return join(home, path.slice(2));
20
20
  }
21
21
  return resolve(path);
@@ -85,7 +85,7 @@ export function defaultFolder(provider, env) {
85
85
  if (provider.home === undefined) {
86
86
  return '';
87
87
  }
88
- return env[provider.home.env] ?? join(env.HOME ?? homedir(), provider.home.fallback);
88
+ return env[provider.home.env] ?? join(homeFolder(env), provider.home.fallback);
89
89
  }
90
90
  /* The folder a CLI of this account is started with: for a Codex account with a shadow home, that one. */
91
91
  export function accountFolder(id, account, provider, env) {
@@ -1,8 +1,8 @@
1
1
  import { mkdir, readFile } from 'node:fs/promises';
2
- import { homedir } from 'node:os';
3
2
  import { dirname, join } from 'node:path';
4
3
  import { errorText } from '../error-text.js';
5
4
  import { isNotFound, writeAtomic } from '../fs.js';
5
+ import { homeFolder } from '../home-folder.js';
6
6
  export function codexRulesText(rules) {
7
7
  return ([
8
8
  `# Written by ${rules.app}; it is rewritten when it changes.`,
@@ -14,7 +14,7 @@ export function codexRulesPathIn(folder, rules) {
14
14
  return join(folder, 'rules', `${rules.app.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.rules`);
15
15
  }
16
16
  export function defaultCodexHome(env = process.env) {
17
- return env.CODEX_HOME ?? join(env.HOME ?? homedir(), '.codex');
17
+ return env.CODEX_HOME ?? join(homeFolder(env), '.codex');
18
18
  }
19
19
  /* Idempotent: a file that already says the same is left alone. */
20
20
  export async function installCodexRules(path, rules) {
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { killProcessTree } from './chat/chat-process.js';
3
+ import { spawnTarget } from './windows-command.js';
3
4
  // What a command and everything it started get between the SIGTERM and the SIGKILL.
4
5
  const KILL_GRACE_MS = 2000;
5
6
  // Stdout and stderr together; a short command that prints more is ended and fails instead of filling memory.
@@ -12,10 +13,12 @@ export function runProcess(command, options = {}) {
12
13
  reject(new Error('No command to run'));
13
14
  return;
14
15
  }
15
- const child = spawn(executable, args, {
16
+ const target = spawnTarget(executable, args, options.env === undefined ? {} : { env: options.env });
17
+ const child = spawn(target.file, target.args, {
16
18
  ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
17
19
  ...(options.env === undefined ? {} : { env: options.env }),
18
20
  stdio: [options.stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
21
+ windowsVerbatimArguments: target.windowsVerbatimArguments ?? false,
19
22
  // A group of its own, so ending the command also ends what it started; Windows ends the tree through `taskkill`.
20
23
  detached: process.platform !== 'win32',
21
24
  windowsHide: true
@@ -1,10 +1,10 @@
1
- import { homedir } from 'node:os';
2
1
  import { join } from 'node:path';
2
+ import { homeFolder } from '../home-folder.js';
3
3
  /* Where in its config folder each CLI leaves its transcripts. */
4
4
  const TRANSCRIPTS = { claude: 'projects', codex: 'sessions' };
5
5
  /* The transcripts of the default accounts, found through the variables the CLIs themselves read. */
6
6
  export function usageRoots(env = process.env) {
7
- const home = env.HOME ?? homedir();
7
+ const home = homeFolder(env);
8
8
  return [
9
9
  { provider: 'claude', path: join(env.CLAUDE_CONFIG_DIR ?? join(home, '.claude'), TRANSCRIPTS.claude) },
10
10
  { provider: 'codex', path: join(env.CODEX_HOME ?? join(home, '.codex'), TRANSCRIPTS.codex) }
@@ -0,0 +1,11 @@
1
+ export interface SpawnTarget {
2
+ file: string;
3
+ args: string[];
4
+ windowsVerbatimArguments?: boolean;
5
+ }
6
+ export interface SpawnTargetOptions {
7
+ env?: Record<string, string | undefined>;
8
+ platform?: NodeJS.Platform;
9
+ isFile?(path: string): boolean;
10
+ }
11
+ export declare function spawnTarget(command: string, args: readonly string[], options?: SpawnTargetOptions): SpawnTarget;
@@ -0,0 +1,71 @@
1
+ import { statSync } from 'node:fs';
2
+ import { win32 } from 'node:path';
3
+ const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD';
4
+ // What cmd.exe reads as syntax outside quotes, and so escapes with a caret.
5
+ const CMD_SPECIAL = /([()\][%!^"`<>&|;, *?])/g;
6
+ /*
7
+ * The file and arguments a spawn starts a command with. On Windows a bare name is looked up on the
8
+ * PATH with PATHEXT, which CreateProcess does not do, and a `.cmd` or `.bat` (npm installs every CLI
9
+ * as one) runs through cmd.exe, as CreateProcess cannot start a batch file itself. Elsewhere nothing changes.
10
+ */
11
+ export function spawnTarget(command, args, options = {}) {
12
+ const { env = process.env, platform = process.platform, isFile = existingFile } = options;
13
+ if (platform !== 'win32') {
14
+ return { file: command, args: [...args] };
15
+ }
16
+ const resolved = findOnPath(command, env, isFile) ?? command;
17
+ if (!/\.(cmd|bat)$/i.test(resolved)) {
18
+ return { file: resolved, args: [...args] };
19
+ }
20
+ const line = [escapeForCmd(resolved), ...args.map((arg) => escapeForCmd(escapeForCmd(quoteArgument(arg))))].join(' ');
21
+ return { file: variable(env, 'ComSpec') ?? 'cmd.exe', args: ['/d', '/s', '/c', `"${line}"`], windowsVerbatimArguments: true };
22
+ }
23
+ function findOnPath(command, env, isFile) {
24
+ const extensions = (variable(env, 'PATHEXT') ?? DEFAULT_PATHEXT)
25
+ .split(';')
26
+ .map((extension) => extension.trim().toLowerCase())
27
+ .filter((extension) => extension !== '');
28
+ const hasExtension = extensions.includes(win32.extname(command).toLowerCase());
29
+ const folders = /[\\/]/.test(command)
30
+ ? ['']
31
+ : (variable(env, 'PATH') ?? '')
32
+ .split(';')
33
+ .map((folder) => folder.trim().replace(/^"(.*)"$/, '$1'))
34
+ .filter((folder) => folder !== '');
35
+ for (const folder of folders) {
36
+ const base = folder === '' ? command : win32.join(folder, command);
37
+ const candidates = hasExtension ? [base] : extensions.map((extension) => `${base}${extension}`);
38
+ const found = candidates.find((candidate) => isFile(candidate));
39
+ if (found !== undefined) {
40
+ return found;
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+ // Windows treats variable names case-insensitively, and an environment often carries `Path`.
46
+ function variable(env, name) {
47
+ const key = Object.keys(env).find((candidate) => candidate.toUpperCase() === name.toUpperCase());
48
+ return key === undefined ? undefined : env[key];
49
+ }
50
+ /* One argument as the C runtime splits a command line: quotes around it, a quote and the backslashes before it escaped. */
51
+ function quoteArgument(arg) {
52
+ if (/[\r\n]/.test(arg)) {
53
+ throw new Error('An argument to a batch file cannot hold a line break');
54
+ }
55
+ return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1')}"`;
56
+ }
57
+ /*
58
+ * Carets keep cmd.exe from reading the argument as syntax. An argument gets them twice: cmd.exe takes
59
+ * one set as it runs the line, and the batch file reads them again where it hands `%*` on.
60
+ */
61
+ function escapeForCmd(text) {
62
+ return text.replace(CMD_SPECIAL, '^$1');
63
+ }
64
+ function existingFile(path) {
65
+ try {
66
+ return statSync(path).isFile();
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.29.1",
3
+ "version": "0.29.3",
4
4
  "description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -81,8 +81,8 @@
81
81
  "test": "bun --conditions=source test"
82
82
  },
83
83
  "dependencies": {
84
- "@adecore/agent-contracts": "0.29.1",
85
- "@adecore/intelligent-ui": "0.29.1",
84
+ "@adecore/agent-contracts": "0.29.3",
85
+ "@adecore/intelligent-ui": "0.29.3",
86
86
  "zod": "^4.6.5"
87
87
  },
88
88
  "devDependencies": {
@@ -1,4 +1,3 @@
1
- import { homedir } from 'node:os';
2
1
  import { join } from 'node:path';
3
2
  import {
4
3
  ChatAttachmentUploadsSchema,
@@ -64,6 +63,7 @@ import { readableAssistantText } from './readable-text.ts';
64
63
  import { SubagentReader, type SubagentReaderOptions } from './subagent-reader.ts';
65
64
  import { ChatUiQueries, type ChatUiHost } from './ui-queries.ts';
66
65
  import type { VisualInput, VisualStore } from './visual-store.ts';
66
+ import { homeFolder } from '../home-folder.ts';
67
67
 
68
68
  /* A turn that was running when the host went down, and the attempt that would take it up again. */
69
69
  export interface InterruptedRun {
@@ -442,7 +442,7 @@ export class ChatCore {
442
442
  const provider = this.providers.get(kind);
443
443
  const catalog = provider.catalog;
444
444
  const selection = catalog.normalize(stored?.info.selection ?? this.hooks.openingSelection?.(payload.chatId, kind) ?? payload.selection);
445
- const cwd = stored?.info.cwd ?? payload.cwd ?? this.env.HOME ?? homedir();
445
+ const cwd = stored?.info.cwd ?? payload.cwd ?? homeFolder(this.env);
446
446
  await this.hooks.admit?.(payload.chatId, cwd);
447
447
  // A thread on disk keeps its account too. One it lost still opens, and says why on its next turn.
448
448
  // A new one without an account takes the person's pick for this CLI, which is refused rather than replaced when it went.
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { Readable } from 'node:stream';
3
+ import { spawnTarget } from '../windows-command.ts';
3
4
 
4
5
  /*
5
6
  * The part of a child process a chat backend talks to: lines in over stdin, lines out over stdout,
@@ -37,10 +38,12 @@ export function spawnChatProcess(options: ChatSpawnOptions): ChatProcess {
37
38
  if (command === undefined) {
38
39
  throw new Error('No command to start');
39
40
  }
40
- const child = spawn(command, args, {
41
+ const target = spawnTarget(command, args, { env: options.env });
42
+ const child = spawn(target.file, target.args, {
41
43
  cwd: options.cwd,
42
44
  env: options.env,
43
45
  stdio: ['pipe', 'pipe', 'pipe'],
46
+ windowsVerbatimArguments: target.windowsVerbatimArguments ?? false,
44
47
  // A group of its own, so ending the chat also ends what its agent started (a dev server, a watcher).
45
48
  // On Windows that would open a console window of its own instead; `taskkill` reaches the tree there.
46
49
  detached: process.platform !== 'win32',
@@ -198,6 +198,8 @@ export class ChatSession {
198
198
  // The last turn the main agent spoke in; a turn opened only for a subagent's request gets no `result`.
199
199
  private spokenTurnId: string | null = null;
200
200
  private titleTimer: ReturnType<typeof setTimeout> | null = null;
201
+ /* Checkpoints still asking git, which `dispose` waits for: the host may remove the folder next. */
202
+ private readonly checkpointWork = new Set<Promise<void>>();
201
203
  // A name is asked for once per chat this host holds; the turn count keeps it once across restarts.
202
204
  private naming = false;
203
205
  // Set while the host goes down: a CLI dying with it must not end the turn a restart takes up again.
@@ -1341,7 +1343,7 @@ export class ChatSession {
1341
1343
  this.backend?.stop();
1342
1344
  }
1343
1345
 
1344
- /* Ends the CLI and everything it started; settles once it exited or was sent the SIGKILL. */
1346
+ /* Ends the CLI and everything it started; settles once it exited or was sent the SIGKILL and no checkpoint asks git any more. */
1345
1347
  dispose(): Promise<void> {
1346
1348
  this.uiReplies.clear();
1347
1349
  this.clearStartWarning();
@@ -1353,7 +1355,7 @@ export class ChatSession {
1353
1355
  const ended = this.backend?.dispose() ?? Promise.resolve();
1354
1356
  this.backend = null;
1355
1357
  this.starting = null;
1356
- return ended;
1358
+ return Promise.all([ended, ...this.checkpointWork]).then(() => undefined);
1357
1359
  }
1358
1360
 
1359
1361
  /* What a turn changed against its checkpoint: the stored answer, or one taken now while it runs. */
@@ -1645,15 +1647,15 @@ export class ChatSession {
1645
1647
  return Promise.resolve();
1646
1648
  }
1647
1649
  const cwd = this.thread.info.cwd;
1648
- return Promise.all([checkpoints.take(cwd), checkpoints.head?.(cwd) ?? null])
1649
- .then(([tree, head]) => {
1650
+ return this.trackCheckpoint(
1651
+ Promise.all([checkpoints.take(cwd), checkpoints.head?.(cwd) ?? null]).then(([tree, head]) => {
1650
1652
  const turn = this.thread.get(turnId);
1651
1653
  if (tree === null || turn?.kind !== 'turn' || turn.state !== 'running' || this.info.activeTurnId !== turnId) {
1652
1654
  return;
1653
1655
  }
1654
1656
  this.emit([this.thread.upsert({ ...turn, checkpoint: tree, ...(head === null ? {} : { checkpointHead: head }) })]);
1655
1657
  })
1656
- .catch(() => undefined);
1658
+ );
1657
1659
  }
1658
1660
 
1659
1661
  /* The diff of a turn that just ended, so a reload shows it without asking git again, and the tree a fork after it starts from. */
@@ -1664,8 +1666,8 @@ export class ChatSession {
1664
1666
  }
1665
1667
  const { checkpoints } = this.options;
1666
1668
  const cwd = this.thread.info.cwd;
1667
- void Promise.all([checkpoints.settle(cwd, turn.checkpoint), checkpoints.head?.(cwd) ?? null])
1668
- .then(([answer, head]) => {
1669
+ void this.trackCheckpoint(
1670
+ Promise.all([checkpoints.settle(cwd, turn.checkpoint), checkpoints.head?.(cwd) ?? null]).then(([answer, head]) => {
1669
1671
  const settled = this.thread.get(turnId);
1670
1672
  if (answer === null || settled?.kind !== 'turn') {
1671
1673
  return;
@@ -1680,7 +1682,14 @@ export class ChatSession {
1680
1682
  ]);
1681
1683
  this.options.persist();
1682
1684
  })
1683
- .catch(() => undefined);
1685
+ );
1686
+ }
1687
+
1688
+ /* Keeps the work in `checkpointWork` until it settled; a failure leaves the turn without a checkpoint. */
1689
+ private trackCheckpoint(work: Promise<void>): Promise<void> {
1690
+ const tracked = work.catch(() => undefined).finally(() => this.checkpointWork.delete(tracked));
1691
+ this.checkpointWork.add(tracked);
1692
+ return tracked;
1684
1693
  }
1685
1694
 
1686
1695
  /*
@@ -1,4 +1,4 @@
1
- import { dirname } from 'node:path';
1
+ import { basename, dirname } from 'node:path';
2
2
  import { workflowAgentIdOf, type ChatInfo, type ChatItem, type ChatSubagentChangedEvent, type ChatSubagentResult } from '@adecore/agent-contracts';
3
3
  import { SYSTEM_WATCH, type DirectoryWatcher, type WatchSeams } from '../watch-seam.ts';
4
4
  import { findSubagentsDir, findWorkflowAgent, readSubagentMetas, TranscriptProjection, type SubagentMeta } from './claude-transcript.ts';
@@ -411,7 +411,7 @@ export class SubagentReader {
411
411
  for (const key of this.dirWatches.get(dir)?.keys ?? []) {
412
412
  const path = this.claudeFiles.get(key)?.path;
413
413
  // A platform that names no file could mean any of them.
414
- if (filename === null || (path !== undefined && path.endsWith(`/${filename}`))) {
414
+ if (filename === null || (path !== undefined && basename(path) === filename)) {
415
415
  this.markChanged(key);
416
416
  }
417
417
  }
@@ -0,0 +1,9 @@
1
+ import { homedir } from 'node:os';
2
+
3
+ /*
4
+ * The home folder the CLIs read their config from. On Windows they take it from USERPROFILE, as Node
5
+ * does; a HOME there is often Git Bash's `/c/Users/...`, which no native program can open.
6
+ */
7
+ export function homeFolder(env: Record<string, string | undefined>, platform: NodeJS.Platform = process.platform): string {
8
+ return (platform === 'win32' ? env.USERPROFILE : env.HOME) || homedir();
9
+ }
@@ -1,8 +1,8 @@
1
- import { homedir } from 'node:os';
2
1
  import { isAbsolute, join, resolve } from 'node:path';
3
2
  import { AgentKindSchema, ProviderAccountSchema, type AgentKind, type ProviderAccount, type ProviderAccountMap } from '@adecore/agent-contracts';
4
3
  import type { ChatProvider } from '../provider.ts';
5
4
  import { DEFAULT_ACCOUNTS_HOST, variablesProblem, type AccountsHost, type ReservedVariables } from './variables.ts';
5
+ import { homeFolder } from '../../home-folder.ts';
6
6
 
7
7
  export type Env = Record<string, string | undefined>;
8
8
 
@@ -18,11 +18,11 @@ export function isDefaultAccount(id: string, account: Pick<ProviderAccount, 'kin
18
18
  }
19
19
 
20
20
  export function expandHome(path: string, env: Env): string {
21
- const home = env.HOME ?? homedir();
21
+ const home = homeFolder(env);
22
22
  if (path === '~') {
23
23
  return home;
24
24
  }
25
- if (path.startsWith('~/')) {
25
+ if (path.startsWith('~/') || (process.platform === 'win32' && path.startsWith('~\\'))) {
26
26
  return join(home, path.slice(2));
27
27
  }
28
28
  return resolve(path);
@@ -103,7 +103,7 @@ export function defaultFolder(provider: ChatProvider, env: Env): string {
103
103
  if (provider.home === undefined) {
104
104
  return '';
105
105
  }
106
- return env[provider.home.env] ?? join(env.HOME ?? homedir(), provider.home.fallback);
106
+ return env[provider.home.env] ?? join(homeFolder(env), provider.home.fallback);
107
107
  }
108
108
 
109
109
  /* The folder a CLI of this account is started with: for a Codex account with a shadow home, that one. */
@@ -1,8 +1,8 @@
1
1
  import { mkdir, readFile } from 'node:fs/promises';
2
- import { homedir } from 'node:os';
3
2
  import { dirname, join } from 'node:path';
4
3
  import { errorText } from '../error-text.ts';
5
4
  import { isNotFound, writeAtomic } from '../fs.ts';
5
+ import { homeFolder } from '../home-folder.ts';
6
6
 
7
7
  /*
8
8
  * The commands a host lets Codex run outside its sandbox without asking: the seatbelt of
@@ -33,7 +33,7 @@ export function codexRulesPathIn(folder: string, rules: CodexRules): string {
33
33
  }
34
34
 
35
35
  export function defaultCodexHome(env: Record<string, string | undefined> = process.env): string {
36
- return env.CODEX_HOME ?? join(env.HOME ?? homedir(), '.codex');
36
+ return env.CODEX_HOME ?? join(homeFolder(env), '.codex');
37
37
  }
38
38
 
39
39
  /* Idempotent: a file that already says the same is left alone. */
@@ -1,5 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { killProcessTree } from './chat/chat-process.ts';
3
+ import { spawnTarget } from './windows-command.ts';
3
4
 
4
5
  export interface ProcessResult {
5
6
  // Null when the process ended on a signal.
@@ -31,10 +32,12 @@ export function runProcess(command: readonly string[], options: RunProcessOption
31
32
  reject(new Error('No command to run'));
32
33
  return;
33
34
  }
34
- const child = spawn(executable, args, {
35
+ const target = spawnTarget(executable, args, options.env === undefined ? {} : { env: options.env });
36
+ const child = spawn(target.file, target.args, {
35
37
  ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
36
38
  ...(options.env === undefined ? {} : { env: options.env }),
37
39
  stdio: [options.stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
40
+ windowsVerbatimArguments: target.windowsVerbatimArguments ?? false,
38
41
  // A group of its own, so ending the command also ends what it started; Windows ends the tree through `taskkill`.
39
42
  detached: process.platform !== 'win32',
40
43
  windowsHide: true
@@ -1,6 +1,6 @@
1
- import { homedir } from 'node:os';
2
1
  import { join } from 'node:path';
3
2
  import type { UsageProvider } from '@adecore/agent-contracts';
3
+ import { homeFolder } from '../home-folder.ts';
4
4
 
5
5
  export interface UsageRootPath {
6
6
  provider: UsageProvider;
@@ -14,7 +14,7 @@ const TRANSCRIPTS: Record<UsageProvider, string> = { claude: 'projects', codex:
14
14
 
15
15
  /* The transcripts of the default accounts, found through the variables the CLIs themselves read. */
16
16
  export function usageRoots(env: Record<string, string | undefined> = process.env): UsageRootPath[] {
17
- const home = env.HOME ?? homedir();
17
+ const home = homeFolder(env);
18
18
  return [
19
19
  { provider: 'claude', path: join(env.CLAUDE_CONFIG_DIR ?? join(home, '.claude'), TRANSCRIPTS.claude) },
20
20
  { provider: 'codex', path: join(env.CODEX_HOME ?? join(home, '.codex'), TRANSCRIPTS.codex) }
@@ -0,0 +1,90 @@
1
+ import { statSync } from 'node:fs';
2
+ import { win32 } from 'node:path';
3
+
4
+ export interface SpawnTarget {
5
+ file: string;
6
+ args: string[];
7
+ // Set for a batch file: the command line is quoted here, so the spawn must pass it on unchanged.
8
+ windowsVerbatimArguments?: boolean;
9
+ }
10
+
11
+ export interface SpawnTargetOptions {
12
+ env?: Record<string, string | undefined>;
13
+ platform?: NodeJS.Platform;
14
+ isFile?(path: string): boolean;
15
+ }
16
+
17
+ const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD';
18
+ // What cmd.exe reads as syntax outside quotes, and so escapes with a caret.
19
+ const CMD_SPECIAL = /([()\][%!^"`<>&|;, *?])/g;
20
+
21
+ /*
22
+ * The file and arguments a spawn starts a command with. On Windows a bare name is looked up on the
23
+ * PATH with PATHEXT, which CreateProcess does not do, and a `.cmd` or `.bat` (npm installs every CLI
24
+ * as one) runs through cmd.exe, as CreateProcess cannot start a batch file itself. Elsewhere nothing changes.
25
+ */
26
+ export function spawnTarget(command: string, args: readonly string[], options: SpawnTargetOptions = {}): SpawnTarget {
27
+ const { env = process.env, platform = process.platform, isFile = existingFile } = options;
28
+ if (platform !== 'win32') {
29
+ return { file: command, args: [...args] };
30
+ }
31
+ const resolved = findOnPath(command, env, isFile) ?? command;
32
+ if (!/\.(cmd|bat)$/i.test(resolved)) {
33
+ return { file: resolved, args: [...args] };
34
+ }
35
+ const line = [escapeForCmd(resolved), ...args.map((arg) => escapeForCmd(escapeForCmd(quoteArgument(arg))))].join(' ');
36
+ return { file: variable(env, 'ComSpec') ?? 'cmd.exe', args: ['/d', '/s', '/c', `"${line}"`], windowsVerbatimArguments: true };
37
+ }
38
+
39
+ function findOnPath(command: string, env: Record<string, string | undefined>, isFile: (path: string) => boolean): string | null {
40
+ const extensions = (variable(env, 'PATHEXT') ?? DEFAULT_PATHEXT)
41
+ .split(';')
42
+ .map((extension) => extension.trim().toLowerCase())
43
+ .filter((extension) => extension !== '');
44
+ const hasExtension = extensions.includes(win32.extname(command).toLowerCase());
45
+ const folders = /[\\/]/.test(command)
46
+ ? ['']
47
+ : (variable(env, 'PATH') ?? '')
48
+ .split(';')
49
+ .map((folder) => folder.trim().replace(/^"(.*)"$/, '$1'))
50
+ .filter((folder) => folder !== '');
51
+ for (const folder of folders) {
52
+ const base = folder === '' ? command : win32.join(folder, command);
53
+ const candidates = hasExtension ? [base] : extensions.map((extension) => `${base}${extension}`);
54
+ const found = candidates.find((candidate) => isFile(candidate));
55
+ if (found !== undefined) {
56
+ return found;
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+
62
+ // Windows treats variable names case-insensitively, and an environment often carries `Path`.
63
+ function variable(env: Record<string, string | undefined>, name: string): string | undefined {
64
+ const key = Object.keys(env).find((candidate) => candidate.toUpperCase() === name.toUpperCase());
65
+ return key === undefined ? undefined : env[key];
66
+ }
67
+
68
+ /* One argument as the C runtime splits a command line: quotes around it, a quote and the backslashes before it escaped. */
69
+ function quoteArgument(arg: string): string {
70
+ if (/[\r\n]/.test(arg)) {
71
+ throw new Error('An argument to a batch file cannot hold a line break');
72
+ }
73
+ return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1')}"`;
74
+ }
75
+
76
+ /*
77
+ * Carets keep cmd.exe from reading the argument as syntax. An argument gets them twice: cmd.exe takes
78
+ * one set as it runs the line, and the batch file reads them again where it hands `%*` on.
79
+ */
80
+ function escapeForCmd(text: string): string {
81
+ return text.replace(CMD_SPECIAL, '^$1');
82
+ }
83
+
84
+ function existingFile(path: string): boolean {
85
+ try {
86
+ return statSync(path).isFile();
87
+ } catch {
88
+ return false;
89
+ }
90
+ }