@quadient/skillshare 1.8.1 → 1.10.0
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/README.md +43 -1
- package/dist/agents-claude.js +274 -0
- package/dist/agents-codex.js +276 -0
- package/dist/agents-detect.js +147 -0
- package/dist/agents-process.js +50 -0
- package/dist/agents-session.js +267 -0
- package/dist/agents.js +230 -0
- package/dist/help.js +31 -15
- package/dist/index.js +79 -49
- package/dist/output.js +167 -0
- package/dist/pages.js +30 -1
- package/dist/teams.js +12 -4
- package/package.json +2 -2
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { basename, isAbsolute, join, resolve } from 'node:path';
|
|
5
|
+
/** Model aliases Claude Code resolves to the latest release; Claude Code itself has no command that lists models. */
|
|
6
|
+
export const CLAUDE_MODELS = ['fable', 'opus', 'sonnet', 'haiku'];
|
|
7
|
+
export const CLAUDE_EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
8
|
+
const NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/;
|
|
9
|
+
export function profilesPath(home = homedir()) {
|
|
10
|
+
return process.platform === 'win32'
|
|
11
|
+
? join(process.env.APPDATA ?? join(home, 'AppData', 'Roaming'), 'skillshare', 'agents.json')
|
|
12
|
+
: join(home, '.config', 'skillshare', 'agents.json');
|
|
13
|
+
}
|
|
14
|
+
export async function detectAgents(options = {}) {
|
|
15
|
+
const home = options.home ?? homedir();
|
|
16
|
+
const env = options.env ?? process.env;
|
|
17
|
+
const version = options.version ?? commandVersion;
|
|
18
|
+
const read = options.read ?? readOptional;
|
|
19
|
+
const agents = [];
|
|
20
|
+
const problems = [];
|
|
21
|
+
const claudeVersion = await version('claude');
|
|
22
|
+
if (claudeVersion !== null) {
|
|
23
|
+
const settings = parseJson(await read(join(env.CLAUDE_CONFIG_DIR ?? join(home, '.claude'), 'settings.json')));
|
|
24
|
+
const configured = Array.isArray(settings?.availableModels) ? settings.availableModels.filter((m) => typeof m === 'string') : [];
|
|
25
|
+
const models = [...new Set([...CLAUDE_MODELS, ...configured])].filter(id => NAME.test(id) && id !== 'default')
|
|
26
|
+
.map(id => ({ id, reasoningLevels: CLAUDE_EFFORT_LEVELS }));
|
|
27
|
+
agents.push({ kind: 'claude', version: parseVersion(claudeVersion), models, warnings: [] });
|
|
28
|
+
}
|
|
29
|
+
const codexVersion = await version('codex');
|
|
30
|
+
if (codexVersion !== null) {
|
|
31
|
+
const codexHome = env.CODEX_HOME ?? join(home, '.codex');
|
|
32
|
+
const warnings = [];
|
|
33
|
+
const cache = parseJson(await read(join(codexHome, 'models_cache.json')));
|
|
34
|
+
const models = Array.isArray(cache?.models) ? cache.models
|
|
35
|
+
.filter((m) => m?.visibility === 'list' && typeof m.slug === 'string' && NAME.test(m.slug))
|
|
36
|
+
.map((m) => {
|
|
37
|
+
const levels = (Array.isArray(m.supported_reasoning_levels) ? m.supported_reasoning_levels : [])
|
|
38
|
+
.map((level) => typeof level === 'string' ? level : level?.effort)
|
|
39
|
+
.filter((level) => typeof level === 'string' && NAME.test(level) && level !== 'default');
|
|
40
|
+
const fallback = typeof m.default_reasoning_level === 'string' && levels.includes(m.default_reasoning_level) ? m.default_reasoning_level : undefined;
|
|
41
|
+
return { id: m.slug, displayName: typeof m.display_name === 'string' ? m.display_name.slice(0, 100) : undefined,
|
|
42
|
+
reasoningLevels: levels.slice(0, 16), defaultReasoning: fallback };
|
|
43
|
+
}).slice(0, 64) : [];
|
|
44
|
+
if (models.length === 0)
|
|
45
|
+
warnings.push('No Codex model list found in models_cache.json. Run codex once to refresh it; only the default model is offered.');
|
|
46
|
+
const configured = defaultCodexModel(await read(join(codexHome, 'config.toml')));
|
|
47
|
+
if (configured && models.length > 0 && !models.some(m => m.id === configured))
|
|
48
|
+
warnings.push(`The default model "${configured}" from config.toml is not in the Codex model list and may be rejected; pick a model explicitly.`);
|
|
49
|
+
agents.push({ kind: 'codex', version: parseVersion(codexVersion), models, warnings });
|
|
50
|
+
}
|
|
51
|
+
const profiles = parseProfiles(await read(profilesPath(home)), home, problems);
|
|
52
|
+
for (const profile of profiles)
|
|
53
|
+
if (!agents.some(agent => agent.kind === profile.agent))
|
|
54
|
+
problems.push(`Profile "${profile.name}" uses ${profile.agent}, which is not installed.`);
|
|
55
|
+
return {
|
|
56
|
+
capabilities: { agents, profiles: profiles.map(p => ({ name: p.name, agent: p.agent, model: p.model, reasoning: p.reasoning,
|
|
57
|
+
workdir: p.workdir ? basename(p.workdir) : undefined, ...(p.sandbox ? { sandbox: true } : {}) })) },
|
|
58
|
+
profiles,
|
|
59
|
+
problems,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export function parseProfiles(text, home, problems = []) {
|
|
63
|
+
if (text === null)
|
|
64
|
+
return [];
|
|
65
|
+
const parsed = parseJson(text);
|
|
66
|
+
if (!Array.isArray(parsed?.profiles)) {
|
|
67
|
+
problems.push(`${profilesPath(home)} must contain { "profiles": [...] }.`);
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
const profiles = [];
|
|
71
|
+
for (const raw of parsed.profiles) {
|
|
72
|
+
const name = raw?.name;
|
|
73
|
+
if (typeof name !== 'string' || !NAME.test(name)) {
|
|
74
|
+
problems.push('Skipped a profile without a valid name.');
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (raw.agent !== 'claude' && raw.agent !== 'codex') {
|
|
78
|
+
problems.push(`Profile "${name}" needs "agent": "claude" or "codex".`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (profiles.some(p => p.name === name)) {
|
|
82
|
+
problems.push(`Profile "${name}" is declared twice.`);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const optional = (key) => typeof raw[key] === 'string' && NAME.test(raw[key]) ? raw[key] : undefined;
|
|
86
|
+
if (raw.model !== undefined && !optional('model') || raw.reasoning !== undefined && !optional('reasoning')) {
|
|
87
|
+
problems.push(`Profile "${name}" has an invalid model or reasoning.`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (raw.sandbox !== undefined && typeof raw.sandbox !== 'boolean') {
|
|
91
|
+
problems.push(`Profile "${name}" sandbox must be true or false.`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (raw.args !== undefined && (!Array.isArray(raw.args) || raw.args.some(arg => typeof arg !== 'string'))) {
|
|
95
|
+
problems.push(`Profile "${name}" args must be a list of strings.`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
profiles.push({ name, agent: raw.agent, model: optional('model'), reasoning: optional('reasoning'),
|
|
99
|
+
workdir: typeof raw.workdir === 'string' && raw.workdir.length > 0 ? expandHome(raw.workdir, home) : undefined,
|
|
100
|
+
args: raw.args ?? [], ...(raw.sandbox === true ? { sandbox: true } : {}) });
|
|
101
|
+
}
|
|
102
|
+
return profiles.slice(0, 32);
|
|
103
|
+
}
|
|
104
|
+
export function expandHome(path, home = homedir()) {
|
|
105
|
+
const expanded = path === '~' ? home : path.startsWith('~/') ? join(home, path.slice(2)) : path;
|
|
106
|
+
return isAbsolute(expanded) ? expanded : resolve(expanded);
|
|
107
|
+
}
|
|
108
|
+
/** Reads the top-level model key only; profiles and providers further down the file are ignored. */
|
|
109
|
+
export function defaultCodexModel(toml) {
|
|
110
|
+
if (!toml)
|
|
111
|
+
return undefined;
|
|
112
|
+
for (const line of toml.split(/\r?\n/)) {
|
|
113
|
+
if (/^\s*\[/.test(line))
|
|
114
|
+
return undefined;
|
|
115
|
+
const match = /^\s*model\s*=\s*"([^"]+)"/.exec(line);
|
|
116
|
+
if (match)
|
|
117
|
+
return match[1];
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
function parseVersion(output) {
|
|
122
|
+
return (/\d+\.\d+(?:\.\d+)?(?:[-+][\w.]+)?/.exec(output)?.[0] ?? output.trim()).slice(0, 100) || undefined;
|
|
123
|
+
}
|
|
124
|
+
function parseJson(text) {
|
|
125
|
+
if (text === null)
|
|
126
|
+
return null;
|
|
127
|
+
try {
|
|
128
|
+
const value = JSON.parse(text);
|
|
129
|
+
return value && typeof value === 'object' ? value : null;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async function readOptional(path) {
|
|
136
|
+
try {
|
|
137
|
+
return await readFile(path, 'utf8');
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function commandVersion(command) {
|
|
144
|
+
return new Promise(resolvePromise => {
|
|
145
|
+
execFile(command, ['--version'], { timeout: 15_000, windowsHide: true, shell: process.platform === 'win32' }, (error, stdout) => resolvePromise(error ? null : stdout.toString()));
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
/**
|
|
4
|
+
* Starts an agent that speaks newline-delimited JSON on stdio. Tokens go through the environment only. A separate
|
|
5
|
+
* process group lets stopping the agent also stop the shell commands it runs.
|
|
6
|
+
*/
|
|
7
|
+
export function startAgentProcess(options) {
|
|
8
|
+
const posix = process.platform !== 'win32';
|
|
9
|
+
const child = (options.spawnAgent ?? spawn)(options.command, options.args, {
|
|
10
|
+
cwd: options.cwd, env: { ...process.env, ...options.env }, stdio: ['pipe', 'pipe', 'pipe'],
|
|
11
|
+
detached: posix, windowsHide: true, shell: !posix,
|
|
12
|
+
});
|
|
13
|
+
let stderr = '';
|
|
14
|
+
child.stderr?.on('data', chunk => { stderr = (stderr + chunk).slice(-4000); });
|
|
15
|
+
child.stdin?.on('error', () => { });
|
|
16
|
+
if (child.stdout)
|
|
17
|
+
createInterface({ input: child.stdout, crlfDelay: Infinity }).on('line', line => { if (line.trim())
|
|
18
|
+
options.onLine(line); });
|
|
19
|
+
const exited = new Promise(resolve => {
|
|
20
|
+
child.once('error', error => { stderr = (stderr + '\n' + error.message).slice(-4000); resolve(null); });
|
|
21
|
+
child.once('exit', code => resolve(code));
|
|
22
|
+
});
|
|
23
|
+
let killing = null;
|
|
24
|
+
return {
|
|
25
|
+
child,
|
|
26
|
+
exited,
|
|
27
|
+
stderrTail: () => stderr.trim().split(/\r?\n/).filter(line => line.trim()).slice(-3).join(' ').slice(-300),
|
|
28
|
+
write: line => child.stdin?.writable ? child.stdin.write(line + '\n') : false,
|
|
29
|
+
kill() {
|
|
30
|
+
killing ??= (async () => {
|
|
31
|
+
const signal = (sig) => {
|
|
32
|
+
try {
|
|
33
|
+
if (posix && child.pid)
|
|
34
|
+
process.kill(-child.pid, sig);
|
|
35
|
+
else
|
|
36
|
+
child.kill(sig);
|
|
37
|
+
}
|
|
38
|
+
catch { }
|
|
39
|
+
};
|
|
40
|
+
child.stdin?.end();
|
|
41
|
+
signal('SIGTERM');
|
|
42
|
+
const timer = setTimeout(() => signal('SIGKILL'), 5000);
|
|
43
|
+
timer.unref?.();
|
|
44
|
+
await exited;
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
})();
|
|
47
|
+
return killing;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
/** Read-only tools Claude Code may use in a sandboxed session; the session's MCP servers are added per server. */
|
|
3
|
+
export const CLAUDE_SANDBOX_TOOLS = ['Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch'];
|
|
4
|
+
export const SANDBOX_NOTE = 'This session is sandboxed: you can read files, search and use the provided MCP tools, but you cannot change files or ' +
|
|
5
|
+
'run commands that modify anything.';
|
|
6
|
+
export const DEFAULT = 'default';
|
|
7
|
+
const MAX_TEXT = 50_000;
|
|
8
|
+
export function isSandboxed(configuration, options) {
|
|
9
|
+
return options.sandbox === true || options.profile?.sandbox === true || configuration.sandbox === true;
|
|
10
|
+
}
|
|
11
|
+
/** MCP server names become tool prefixes and config keys, so they are reduced to a safe alphabet. */
|
|
12
|
+
export function mcpServerName(server, index) {
|
|
13
|
+
return server.name.replace(/[^A-Za-z0-9_-]/g, '_') || `server_${index}`;
|
|
14
|
+
}
|
|
15
|
+
/** Tokens go to environment variables only, so they never appear in a command line or a protocol frame. */
|
|
16
|
+
export function mcpTokenEnvironment(servers) {
|
|
17
|
+
return Object.fromEntries(servers.map((server, index) => [`SKILLSHARE_MCP_TOKEN_${index}`, server.bearerToken]));
|
|
18
|
+
}
|
|
19
|
+
export function errorMessage(error) {
|
|
20
|
+
return error instanceof Error ? error.message : String(error);
|
|
21
|
+
}
|
|
22
|
+
export function clip(text, length = MAX_TEXT) {
|
|
23
|
+
return text.length <= length ? text : text.slice(0, length) + '…';
|
|
24
|
+
}
|
|
25
|
+
export function oneLine(text, length = 300) {
|
|
26
|
+
return clip(text.replace(/\s+/g, ' ').trim(), length);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Batches a session's events for upload. Updates of the same transcript entry are merged, so a fast stream of text
|
|
30
|
+
* deltas costs one request per flush, and a slow connection never queues more than one copy of each entry.
|
|
31
|
+
*/
|
|
32
|
+
export class EventQueue {
|
|
33
|
+
upload;
|
|
34
|
+
intervalMs;
|
|
35
|
+
onGone;
|
|
36
|
+
queue = [];
|
|
37
|
+
flushing = null;
|
|
38
|
+
timer = null;
|
|
39
|
+
gone = false;
|
|
40
|
+
constructor(upload, intervalMs = 1000, onGone = () => { }) {
|
|
41
|
+
this.upload = upload;
|
|
42
|
+
this.intervalMs = intervalMs;
|
|
43
|
+
this.onGone = onGone;
|
|
44
|
+
}
|
|
45
|
+
push(event) {
|
|
46
|
+
if (this.gone)
|
|
47
|
+
return;
|
|
48
|
+
if (event.type === 'item') {
|
|
49
|
+
const index = this.queue.findIndex(e => e.type === 'item' && e.key === event.key);
|
|
50
|
+
if (index >= 0) {
|
|
51
|
+
// Only the latest state of an entry matters; keep its place so the transcript order stays stable.
|
|
52
|
+
this.queue[index] = { ...this.queue[index], ...event };
|
|
53
|
+
this.schedule();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
this.queue.push(event);
|
|
58
|
+
if (event.type === 'item')
|
|
59
|
+
this.schedule();
|
|
60
|
+
else
|
|
61
|
+
void this.flush();
|
|
62
|
+
}
|
|
63
|
+
schedule() {
|
|
64
|
+
if (this.timer)
|
|
65
|
+
return;
|
|
66
|
+
this.timer = setTimeout(() => { this.timer = null; void this.flush(); }, this.intervalMs);
|
|
67
|
+
}
|
|
68
|
+
/** Sends everything queued so far, retrying with backoff until it is accepted or the server rejects the session. */
|
|
69
|
+
flush() {
|
|
70
|
+
if (this.flushing)
|
|
71
|
+
return this.flushing.then(() => this.queue.length > 0 ? this.flush() : undefined);
|
|
72
|
+
this.flushing = (async () => {
|
|
73
|
+
let failures = 0;
|
|
74
|
+
while (this.queue.length > 0 && !this.gone) {
|
|
75
|
+
const batch = this.queue.slice(0, 200);
|
|
76
|
+
const result = await this.upload(batch).catch(() => 'retry');
|
|
77
|
+
if (result === 'gone') {
|
|
78
|
+
this.gone = true;
|
|
79
|
+
this.queue = [];
|
|
80
|
+
this.onGone();
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
if (result === 'retry') {
|
|
84
|
+
await delay(Math.min(30_000, 1000 * 2 ** Math.min(failures++, 5)));
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
failures = 0;
|
|
88
|
+
this.queue.splice(0, batch.length);
|
|
89
|
+
}
|
|
90
|
+
})().finally(() => { this.flushing = null; });
|
|
91
|
+
return this.flushing;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Runs one session on this computer: starts the agent, feeds it the user's messages one turn at a time, enforces the turn
|
|
96
|
+
* timeout, closes the agent after a quiet period and reports everything as SessionEvents. The server decides what happens
|
|
97
|
+
* next; the runner only reports and obeys.
|
|
98
|
+
*/
|
|
99
|
+
export class SessionRunner {
|
|
100
|
+
options;
|
|
101
|
+
sessionId;
|
|
102
|
+
leaseToken;
|
|
103
|
+
events;
|
|
104
|
+
driver;
|
|
105
|
+
seen = new Set();
|
|
106
|
+
pending = [];
|
|
107
|
+
now;
|
|
108
|
+
opened = null;
|
|
109
|
+
openSucceeded = false;
|
|
110
|
+
turnRunning = false;
|
|
111
|
+
interruptRequested = false;
|
|
112
|
+
idleTimer = null;
|
|
113
|
+
closing = null;
|
|
114
|
+
closedReported = false;
|
|
115
|
+
done;
|
|
116
|
+
resolveDone;
|
|
117
|
+
constructor(options) {
|
|
118
|
+
this.options = options;
|
|
119
|
+
this.sessionId = options.sessionId;
|
|
120
|
+
this.leaseToken = options.leaseToken;
|
|
121
|
+
this.now = options.now ?? Date.now;
|
|
122
|
+
this.done = new Promise(resolve => { this.resolveDone = resolve; });
|
|
123
|
+
// A rejected lease means the server moved on; the agent must stop without reporting anything more.
|
|
124
|
+
this.events = new EventQueue(options.upload, options.flushIntervalMs, () => void this.stop());
|
|
125
|
+
this.driver = options.driver({
|
|
126
|
+
sessionId: options.sessionId,
|
|
127
|
+
start: options.start,
|
|
128
|
+
workdir: options.workdir,
|
|
129
|
+
profile: options.profile,
|
|
130
|
+
sandbox: options.sandbox,
|
|
131
|
+
item: item => this.events.push({ type: 'item', ...item, text: item.text === undefined ? undefined : clip(item.text) }),
|
|
132
|
+
thread: threadId => this.events.push({ type: 'thread', threadId }),
|
|
133
|
+
exited: error => void this.finish(error ?? 'The agent stopped unexpectedly.'),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
get stopping() { return this.closing !== null; }
|
|
137
|
+
/** Queues messages for the next turns; the server may send a message again, so repeats are ignored. */
|
|
138
|
+
send(messages) {
|
|
139
|
+
for (const message of messages) {
|
|
140
|
+
if (this.seen.has(message.id))
|
|
141
|
+
continue;
|
|
142
|
+
this.seen.add(message.id);
|
|
143
|
+
this.pending.push(message);
|
|
144
|
+
}
|
|
145
|
+
void this.pump();
|
|
146
|
+
}
|
|
147
|
+
async interrupt() {
|
|
148
|
+
if (!this.turnRunning || this.interruptRequested)
|
|
149
|
+
return;
|
|
150
|
+
this.interruptRequested = true;
|
|
151
|
+
await this.driver.interrupt().catch(() => { });
|
|
152
|
+
}
|
|
153
|
+
/** Stops the agent and reports the session as closed, so the server can resume it later or finish ending it. */
|
|
154
|
+
close(error) {
|
|
155
|
+
return this.finish(error);
|
|
156
|
+
}
|
|
157
|
+
/** Stops the agent without reporting, because the server no longer accepts this lease. */
|
|
158
|
+
stop() {
|
|
159
|
+
this.closedReported = true;
|
|
160
|
+
return this.finish();
|
|
161
|
+
}
|
|
162
|
+
async pump() {
|
|
163
|
+
if (this.turnRunning || this.closing)
|
|
164
|
+
return;
|
|
165
|
+
const message = this.pending.shift();
|
|
166
|
+
if (!message) {
|
|
167
|
+
this.armIdle();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
this.clearIdle();
|
|
171
|
+
this.turnRunning = true;
|
|
172
|
+
this.interruptRequested = false;
|
|
173
|
+
const started = this.now();
|
|
174
|
+
if (!this.openSucceeded) {
|
|
175
|
+
try {
|
|
176
|
+
this.opened ??= this.driver.open();
|
|
177
|
+
await this.opened;
|
|
178
|
+
this.openSucceeded = true;
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
const reason = errorMessage(error);
|
|
182
|
+
// A failed resume uses up the message as a failed turn, so the server does not ask for the same start again.
|
|
183
|
+
// A first start that fails has nothing to resume; the server fails the whole session.
|
|
184
|
+
if (this.options.start.resumeThreadId) {
|
|
185
|
+
this.events.push({ type: 'turnStarted', messageId: message.id });
|
|
186
|
+
this.events.push({ type: 'turnCompleted', status: 'failed', error: reason });
|
|
187
|
+
}
|
|
188
|
+
this.turnRunning = false;
|
|
189
|
+
await this.finish(reason);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (this.closing)
|
|
194
|
+
return;
|
|
195
|
+
this.events.push({ type: 'turnStarted', messageId: message.id });
|
|
196
|
+
let outcome;
|
|
197
|
+
try {
|
|
198
|
+
outcome = await this.withTimeout(this.driver.turn(message));
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
outcome = { status: 'failed', error: errorMessage(error) };
|
|
202
|
+
}
|
|
203
|
+
if (this.closing)
|
|
204
|
+
return;
|
|
205
|
+
if (this.interruptRequested && outcome.status === 'failed' && !outcome.error)
|
|
206
|
+
outcome = { ...outcome, status: 'interrupted' };
|
|
207
|
+
this.events.push({ type: 'turnCompleted', status: outcome.status, error: outcome.error,
|
|
208
|
+
stats: { ...outcome.stats, durationMs: this.now() - started } });
|
|
209
|
+
this.turnRunning = false;
|
|
210
|
+
void this.events.flush();
|
|
211
|
+
void this.pump();
|
|
212
|
+
}
|
|
213
|
+
/** Interrupts a turn that runs past its timeout, and gives up on an agent that does not stop when asked. */
|
|
214
|
+
async withTimeout(turn) {
|
|
215
|
+
const timeoutMs = Math.max(1, this.options.start.turnTimeoutSeconds) * 1000;
|
|
216
|
+
let timer;
|
|
217
|
+
let expired = false;
|
|
218
|
+
const timedOut = new Promise(resolve => {
|
|
219
|
+
timer = setTimeout(async () => {
|
|
220
|
+
expired = true;
|
|
221
|
+
await this.interrupt();
|
|
222
|
+
const stopped = await Promise.race([turn.then(() => true), delay(15_000).then(() => false)]);
|
|
223
|
+
resolve(stopped ? { status: 'interrupted', error: `The turn did not finish within ${this.options.start.turnTimeoutSeconds} seconds.` }
|
|
224
|
+
: { status: 'failed', error: `The agent did not stop after ${this.options.start.turnTimeoutSeconds} seconds.` });
|
|
225
|
+
if (!stopped)
|
|
226
|
+
void this.finish(`The agent did not stop after ${this.options.start.turnTimeoutSeconds} seconds.`);
|
|
227
|
+
}, timeoutMs);
|
|
228
|
+
});
|
|
229
|
+
try {
|
|
230
|
+
const outcome = await Promise.race([turn, timedOut]);
|
|
231
|
+
// The interrupt usually ends the turn before the timeout path does; the outcome should still say why it stopped.
|
|
232
|
+
return expired && outcome.status !== 'completed' && !outcome.error
|
|
233
|
+
? { ...outcome, error: `The turn did not finish within ${this.options.start.turnTimeoutSeconds} seconds.` } : outcome;
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
armIdle() {
|
|
240
|
+
this.clearIdle();
|
|
241
|
+
if (this.closing)
|
|
242
|
+
return;
|
|
243
|
+
this.idleTimer = setTimeout(() => void this.close(), this.options.idleMs ?? 15 * 60_000);
|
|
244
|
+
}
|
|
245
|
+
clearIdle() {
|
|
246
|
+
if (this.idleTimer)
|
|
247
|
+
clearTimeout(this.idleTimer);
|
|
248
|
+
this.idleTimer = null;
|
|
249
|
+
}
|
|
250
|
+
finish(error) {
|
|
251
|
+
this.closing ??= (async () => {
|
|
252
|
+
this.clearIdle();
|
|
253
|
+
await this.driver.close().catch(() => { });
|
|
254
|
+
if (this.turnRunning && !this.closedReported) {
|
|
255
|
+
this.events.push({ type: 'turnCompleted', status: 'failed', error: error ?? 'The agent was stopped.' });
|
|
256
|
+
this.turnRunning = false;
|
|
257
|
+
}
|
|
258
|
+
if (!this.closedReported) {
|
|
259
|
+
this.closedReported = true;
|
|
260
|
+
this.events.push({ type: 'closed', error });
|
|
261
|
+
}
|
|
262
|
+
await this.events.flush();
|
|
263
|
+
this.resolveDone();
|
|
264
|
+
})();
|
|
265
|
+
return this.closing;
|
|
266
|
+
}
|
|
267
|
+
}
|