@adecore/agents 0.29.0 → 0.29.2
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/dist/chat/chat-core.js +2 -2
- package/dist/chat/chat-process.js +4 -1
- package/dist/chat/subagent-reader.js +2 -2
- package/dist/fs.d.ts +3 -0
- package/dist/fs.js +12 -1
- package/dist/home-folder.d.ts +1 -0
- package/dist/home-folder.js +8 -0
- package/dist/providers/accounts/accounts.js +4 -4
- package/dist/providers/codex-rules.js +2 -2
- package/dist/record-directory.js +5 -5
- package/dist/run-process.js +4 -1
- package/dist/usage/roots.js +2 -2
- package/dist/windows-command.d.ts +11 -0
- package/dist/windows-command.js +71 -0
- package/package.json +3 -3
- package/src/chat/chat-core.ts +2 -2
- package/src/chat/chat-process.ts +4 -1
- package/src/chat/subagent-reader.ts +2 -2
- package/src/fs.ts +12 -1
- package/src/home-folder.ts +9 -0
- package/src/providers/accounts/accounts.ts +4 -4
- package/src/providers/codex-rules.ts +2 -2
- package/src/record-directory.ts +5 -5
- package/src/run-process.ts +4 -1
- package/src/usage/roots.ts +2 -2
- package/src/windows-command.ts +90 -0
package/dist/chat/chat-core.js
CHANGED
|
@@ -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
|
|
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
|
|
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',
|
|
@@ -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
|
|
343
|
+
if (filename === null || (path !== undefined && basename(path) === filename)) {
|
|
344
344
|
this.markChanged(key);
|
|
345
345
|
}
|
|
346
346
|
}
|
package/dist/fs.d.ts
CHANGED
|
@@ -6,4 +6,7 @@ export declare function writeAtomic(target: string, content: string | Uint8Array
|
|
|
6
6
|
export declare function replaceSync(temp: string, target: string): void;
|
|
7
7
|
export declare function writeAtomicSync(target: string, content: string | Uint8Array, mode?: number): void;
|
|
8
8
|
export declare function isNotFound(e: unknown): boolean;
|
|
9
|
+
export declare function removeFile(path: string, remove?: (path: string, options: {
|
|
10
|
+
force: true;
|
|
11
|
+
}) => Promise<void>): Promise<void>;
|
|
9
12
|
export declare function fileExists(path: string): Promise<boolean>;
|
package/dist/fs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { renameSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { open, rename, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { open, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
4
|
// Windows can refuse a rename while a watcher or an indexer holds the target; a few short retries cover it.
|
|
5
5
|
const RENAME_RETRIES = 5;
|
|
@@ -96,6 +96,17 @@ export function writeAtomicSync(target, content, mode = 0o600) {
|
|
|
96
96
|
export function isNotFound(e) {
|
|
97
97
|
return typeof e === 'object' && e !== null && 'code' in e && e.code === 'ENOENT';
|
|
98
98
|
}
|
|
99
|
+
// Bun on Windows fails a forced rm with EFAULT while another delete of the same file is pending; it goes either way.
|
|
100
|
+
export async function removeFile(path, remove = rm) {
|
|
101
|
+
try {
|
|
102
|
+
await remove(path, { force: true });
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
if (!(typeof e === 'object' && e !== null && 'code' in e && e.code === 'EFAULT')) {
|
|
106
|
+
throw e;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
99
110
|
export async function fileExists(path) {
|
|
100
111
|
try {
|
|
101
112
|
await stat(path);
|
|
@@ -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
|
|
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
|
|
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
|
|
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) {
|
package/dist/record-directory.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { mkdir, readdir, readFile
|
|
1
|
+
import { mkdir, readdir, readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { isNotFound, writeAtomic } from './fs.js';
|
|
3
|
+
import { isNotFound, removeFile, writeAtomic } from './fs.js';
|
|
4
4
|
import { KeyedSerializer } from './serializer.js';
|
|
5
5
|
// The id is a node id or a chat id a client chose, so it is encoded before it becomes a file name.
|
|
6
6
|
export function recordFileName(id) {
|
|
@@ -59,7 +59,7 @@ export class RecordDirectory {
|
|
|
59
59
|
}
|
|
60
60
|
const record = this.keep === null ? parsed.data : this.keep(parsed.data);
|
|
61
61
|
if (record === null) {
|
|
62
|
-
await
|
|
62
|
+
await removeFile(join(this.dir, name));
|
|
63
63
|
continue;
|
|
64
64
|
}
|
|
65
65
|
this.records.set(this.idOf(record), record);
|
|
@@ -89,7 +89,7 @@ export class RecordDirectory {
|
|
|
89
89
|
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
90
90
|
await this.writeFile(this.pathOf(id), JSON.stringify(record));
|
|
91
91
|
if (!this.records.has(id)) {
|
|
92
|
-
await
|
|
92
|
+
await removeFile(this.pathOf(id));
|
|
93
93
|
return false;
|
|
94
94
|
}
|
|
95
95
|
return true;
|
|
@@ -98,7 +98,7 @@ export class RecordDirectory {
|
|
|
98
98
|
/* Forgets a record here and now; its file follows. */
|
|
99
99
|
remove(id) {
|
|
100
100
|
this.records.delete(id);
|
|
101
|
-
return
|
|
101
|
+
return removeFile(this.pathOf(id));
|
|
102
102
|
}
|
|
103
103
|
async prune(drop) {
|
|
104
104
|
for (const listed of this.all()) {
|
package/dist/run-process.js
CHANGED
|
@@ -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
|
|
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
|
package/dist/usage/roots.js
CHANGED
|
@@ -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
|
|
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.
|
|
3
|
+
"version": "0.29.2",
|
|
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.
|
|
85
|
-
"@adecore/intelligent-ui": "0.29.
|
|
84
|
+
"@adecore/agent-contracts": "0.29.2",
|
|
85
|
+
"@adecore/intelligent-ui": "0.29.2",
|
|
86
86
|
"zod": "^4.6.5"
|
|
87
87
|
},
|
|
88
88
|
"devDependencies": {
|
package/src/chat/chat-core.ts
CHANGED
|
@@ -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
|
|
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.
|
package/src/chat/chat-process.ts
CHANGED
|
@@ -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
|
|
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',
|
|
@@ -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
|
|
414
|
+
if (filename === null || (path !== undefined && basename(path) === filename)) {
|
|
415
415
|
this.markChanged(key);
|
|
416
416
|
}
|
|
417
417
|
}
|
package/src/fs.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { renameSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { open, rename, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { open, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
4
|
|
|
5
5
|
// Windows can refuse a rename while a watcher or an indexer holds the target; a few short retries cover it.
|
|
@@ -108,6 +108,17 @@ export function isNotFound(e: unknown): boolean {
|
|
|
108
108
|
return typeof e === 'object' && e !== null && 'code' in e && e.code === 'ENOENT';
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
// Bun on Windows fails a forced rm with EFAULT while another delete of the same file is pending; it goes either way.
|
|
112
|
+
export async function removeFile(path: string, remove: (path: string, options: { force: true }) => Promise<void> = rm): Promise<void> {
|
|
113
|
+
try {
|
|
114
|
+
await remove(path, { force: true });
|
|
115
|
+
} catch (e) {
|
|
116
|
+
if (!(typeof e === 'object' && e !== null && 'code' in e && e.code === 'EFAULT')) {
|
|
117
|
+
throw e;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
111
122
|
export async function fileExists(path: string): Promise<boolean> {
|
|
112
123
|
try {
|
|
113
124
|
await stat(path);
|
|
@@ -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
|
|
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
|
|
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
|
|
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. */
|
package/src/record-directory.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { mkdir, readdir, readFile
|
|
1
|
+
import { mkdir, readdir, readFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import type { z } from 'zod';
|
|
4
|
-
import { isNotFound, writeAtomic } from './fs.ts';
|
|
4
|
+
import { isNotFound, removeFile, writeAtomic } from './fs.ts';
|
|
5
5
|
import { KeyedSerializer } from './serializer.ts';
|
|
6
6
|
|
|
7
7
|
// The id is a node id or a chat id a client chose, so it is encoded before it becomes a file name.
|
|
@@ -74,7 +74,7 @@ export class RecordDirectory<T> {
|
|
|
74
74
|
}
|
|
75
75
|
const record = this.keep === null ? parsed.data : this.keep(parsed.data);
|
|
76
76
|
if (record === null) {
|
|
77
|
-
await
|
|
77
|
+
await removeFile(join(this.dir, name));
|
|
78
78
|
continue;
|
|
79
79
|
}
|
|
80
80
|
this.records.set(this.idOf(record), record);
|
|
@@ -108,7 +108,7 @@ export class RecordDirectory<T> {
|
|
|
108
108
|
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
109
109
|
await this.writeFile(this.pathOf(id), JSON.stringify(record));
|
|
110
110
|
if (!this.records.has(id)) {
|
|
111
|
-
await
|
|
111
|
+
await removeFile(this.pathOf(id));
|
|
112
112
|
return false;
|
|
113
113
|
}
|
|
114
114
|
return true;
|
|
@@ -118,7 +118,7 @@ export class RecordDirectory<T> {
|
|
|
118
118
|
/* Forgets a record here and now; its file follows. */
|
|
119
119
|
remove(id: string): Promise<void> {
|
|
120
120
|
this.records.delete(id);
|
|
121
|
-
return
|
|
121
|
+
return removeFile(this.pathOf(id));
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
async prune(drop: (record: T) => boolean): Promise<void> {
|
package/src/run-process.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/usage/roots.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|