@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.
- package/dist/chat/chat-core.js +2 -2
- package/dist/chat/chat-process.js +4 -1
- package/dist/chat/chat-session.d.ts +2 -0
- package/dist/chat/chat-session.js +14 -10
- package/dist/chat/subagent-reader.js +2 -2
- 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/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/chat-session.ts +17 -8
- package/src/chat/subagent-reader.ts +2 -2
- 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/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',
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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/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.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.
|
|
85
|
-
"@adecore/intelligent-ui": "0.29.
|
|
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": {
|
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',
|
package/src/chat/chat-session.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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/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
|
+
}
|