@quadient/skillshare 1.9.0 → 1.10.1
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 +14 -7
- package/dist/agents-claude.js +322 -0
- package/dist/agents-codex.js +292 -0
- package/dist/agents-detect.js +73 -12
- package/dist/agents-process.js +50 -0
- package/dist/agents-session.js +296 -0
- package/dist/agents.js +64 -52
- package/dist/help.js +5 -5
- package/dist/index.js +3 -2
- package/dist/teams.js +39 -14
- package/package.json +1 -1
- package/dist/agents-run.js +0 -174
package/dist/agents-detect.js
CHANGED
|
@@ -2,6 +2,7 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { basename, isAbsolute, join, resolve } from 'node:path';
|
|
5
|
+
import { startAgentProcess } from './agents-process.js';
|
|
5
6
|
/** Model aliases Claude Code resolves to the latest release; Claude Code itself has no command that lists models. */
|
|
6
7
|
export const CLAUDE_MODELS = ['fable', 'opus', 'sonnet', 'haiku'];
|
|
7
8
|
export const CLAUDE_EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
|
|
@@ -30,19 +31,11 @@ export async function detectAgents(options = {}) {
|
|
|
30
31
|
if (codexVersion !== null) {
|
|
31
32
|
const codexHome = env.CODEX_HOME ?? join(home, '.codex');
|
|
32
33
|
const warnings = [];
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
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) : [];
|
|
34
|
+
// Codex's own model/list reflects the account; its cache file is the fallback when the app-server cannot be asked.
|
|
35
|
+
const listed = await (options.codexModels ?? (() => codexModelList()))().catch(() => null);
|
|
36
|
+
const models = listed ?? cachedCodexModels(parseJson(await read(join(codexHome, 'models_cache.json'))));
|
|
44
37
|
if (models.length === 0)
|
|
45
|
-
warnings.push('
|
|
38
|
+
warnings.push('Codex did not list its models. Run codex once to sign in or refresh it; only the default model is offered.');
|
|
46
39
|
const configured = defaultCodexModel(await read(join(codexHome, 'config.toml')));
|
|
47
40
|
if (configured && models.length > 0 && !models.some(m => m.id === configured))
|
|
48
41
|
warnings.push(`The default model "${configured}" from config.toml is not in the Codex model list and may be rejected; pick a model explicitly.`);
|
|
@@ -59,6 +52,74 @@ export async function detectAgents(options = {}) {
|
|
|
59
52
|
problems,
|
|
60
53
|
};
|
|
61
54
|
}
|
|
55
|
+
/** Keeps the list within what the server accepts: valid names, no "default", and bounded sizes. */
|
|
56
|
+
function codexModel(id, displayName, levels, fallback) {
|
|
57
|
+
if (typeof id !== 'string' || !NAME.test(id) || id === 'default')
|
|
58
|
+
return null;
|
|
59
|
+
const reasoningLevels = levels.filter((level) => typeof level === 'string' && NAME.test(level) && level !== 'default').slice(0, 16);
|
|
60
|
+
return { id, displayName: typeof displayName === 'string' ? displayName.slice(0, 100) : undefined, reasoningLevels,
|
|
61
|
+
defaultReasoning: typeof fallback === 'string' && reasoningLevels.includes(fallback) ? fallback : undefined };
|
|
62
|
+
}
|
|
63
|
+
/** Models from model/list of `codex app-server`, the same list the Codex UI offers; hidden models are left out. */
|
|
64
|
+
export function codexModelsFromList(data) {
|
|
65
|
+
return (Array.isArray(data) ? data : []).filter((m) => m && m.hidden !== true)
|
|
66
|
+
.map((m) => codexModel(m.id ?? m.model, m.displayName, (Array.isArray(m.supportedReasoningEfforts) ? m.supportedReasoningEfforts : []).map((e) => e?.reasoningEffort), m.defaultReasoningEffort))
|
|
67
|
+
.filter((m) => m !== null).slice(0, 64);
|
|
68
|
+
}
|
|
69
|
+
function cachedCodexModels(cache) {
|
|
70
|
+
return (Array.isArray(cache?.models) ? cache.models : []).filter((m) => m?.visibility === 'list')
|
|
71
|
+
.map((m) => codexModel(m.slug, m.display_name, (Array.isArray(m.supported_reasoning_levels) ? m.supported_reasoning_levels : []).map((level) => typeof level === 'string' ? level : level?.effort), m.default_reasoning_level))
|
|
72
|
+
.filter((m) => m !== null).slice(0, 64);
|
|
73
|
+
}
|
|
74
|
+
/** Asks a short-lived `codex app-server` for model/list, following pages, and gives up after the timeout. */
|
|
75
|
+
export async function codexModelList(options = {}) {
|
|
76
|
+
const pending = new Map();
|
|
77
|
+
let nextId = 0;
|
|
78
|
+
const agent = startAgentProcess({
|
|
79
|
+
command: options.command ?? 'codex', args: ['app-server'], cwd: homedir(), env: {}, spawnAgent: options.spawnAgent,
|
|
80
|
+
onLine: line => {
|
|
81
|
+
try {
|
|
82
|
+
const message = JSON.parse(line);
|
|
83
|
+
if (message.id !== undefined && message.method === undefined)
|
|
84
|
+
pending.get(message.id)?.(message);
|
|
85
|
+
}
|
|
86
|
+
catch { }
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
const request = (method, params) => new Promise((resolvePromise, reject) => {
|
|
90
|
+
const id = ++nextId;
|
|
91
|
+
pending.set(id, message => message.error ? reject(new Error(String(message.error.message))) : resolvePromise(message.result ?? {}));
|
|
92
|
+
if (!agent.write(JSON.stringify({ id, method, params })))
|
|
93
|
+
reject(new Error('Codex is not accepting input.'));
|
|
94
|
+
});
|
|
95
|
+
const ask = async () => {
|
|
96
|
+
await request('initialize', { clientInfo: { name: 'skillshare', title: 'Skillshare', version: '0.0.0' }, capabilities: { experimentalApi: true } });
|
|
97
|
+
agent.write(JSON.stringify({ method: 'initialized' }));
|
|
98
|
+
const data = [];
|
|
99
|
+
let cursor;
|
|
100
|
+
for (let page = 0; page < 10; page++) {
|
|
101
|
+
const result = await request('model/list', cursor ? { cursor } : {});
|
|
102
|
+
data.push(...(Array.isArray(result.data) ? result.data : []));
|
|
103
|
+
cursor = typeof result.nextCursor === 'string' && result.nextCursor ? result.nextCursor : undefined;
|
|
104
|
+
if (!cursor)
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
return codexModelsFromList(data);
|
|
108
|
+
};
|
|
109
|
+
let timer;
|
|
110
|
+
try {
|
|
111
|
+
const models = await Promise.race([ask(), agent.exited.then(() => null),
|
|
112
|
+
new Promise(resolvePromise => { timer = setTimeout(() => resolvePromise(null), options.timeoutMs ?? 20_000); })]);
|
|
113
|
+
return models && models.length > 0 ? models : null;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
await agent.kill();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
62
123
|
export function parseProfiles(text, home, problems = []) {
|
|
63
124
|
if (text === null)
|
|
64
125
|
return [];
|
|
@@ -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,296 @@
|
|
|
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
|
+
steering = new Set();
|
|
108
|
+
now;
|
|
109
|
+
opened = null;
|
|
110
|
+
openSucceeded = false;
|
|
111
|
+
turnRunning = false;
|
|
112
|
+
interruptRequested = false;
|
|
113
|
+
idleTimer = null;
|
|
114
|
+
closing = null;
|
|
115
|
+
closedReported = false;
|
|
116
|
+
done;
|
|
117
|
+
resolveDone;
|
|
118
|
+
constructor(options) {
|
|
119
|
+
this.options = options;
|
|
120
|
+
this.sessionId = options.sessionId;
|
|
121
|
+
this.leaseToken = options.leaseToken;
|
|
122
|
+
this.now = options.now ?? Date.now;
|
|
123
|
+
this.done = new Promise(resolve => { this.resolveDone = resolve; });
|
|
124
|
+
// A rejected lease means the server moved on; the agent must stop without reporting anything more.
|
|
125
|
+
this.events = new EventQueue(options.upload, options.flushIntervalMs, () => void this.stop());
|
|
126
|
+
this.driver = options.driver({
|
|
127
|
+
sessionId: options.sessionId,
|
|
128
|
+
start: options.start,
|
|
129
|
+
workdir: options.workdir,
|
|
130
|
+
profile: options.profile,
|
|
131
|
+
sandbox: options.sandbox,
|
|
132
|
+
item: item => this.events.push({ type: 'item', ...item, text: item.text === undefined ? undefined : clip(item.text) }),
|
|
133
|
+
thread: threadId => this.events.push({ type: 'thread', threadId }),
|
|
134
|
+
exited: error => void this.finish(error ?? 'The agent stopped unexpectedly.'),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
get stopping() { return this.closing !== null; }
|
|
138
|
+
/**
|
|
139
|
+
* Queues messages for the next turns, or steers them into the running turn; the server may send a message again, so
|
|
140
|
+
* repeats are ignored.
|
|
141
|
+
*/
|
|
142
|
+
send(messages) {
|
|
143
|
+
for (const message of messages) {
|
|
144
|
+
if (this.seen.has(message.id))
|
|
145
|
+
continue;
|
|
146
|
+
this.seen.add(message.id);
|
|
147
|
+
if (message.steer && this.turnRunning && this.openSucceeded && this.driver.steer)
|
|
148
|
+
this.steer(message);
|
|
149
|
+
else
|
|
150
|
+
this.pending.push(message);
|
|
151
|
+
}
|
|
152
|
+
void this.pump();
|
|
153
|
+
}
|
|
154
|
+
/** A message that misses the running turn becomes the next one, ahead of anything queued after it. */
|
|
155
|
+
steer(message) {
|
|
156
|
+
const task = (async () => {
|
|
157
|
+
let outcome = 'notSent';
|
|
158
|
+
try {
|
|
159
|
+
outcome = await this.driver.steer(message);
|
|
160
|
+
}
|
|
161
|
+
catch { }
|
|
162
|
+
if (this.closing)
|
|
163
|
+
return;
|
|
164
|
+
if (outcome === 'steered')
|
|
165
|
+
this.events.push({ type: 'steered', messageId: message.id });
|
|
166
|
+
else
|
|
167
|
+
this.pending.unshift(message);
|
|
168
|
+
})();
|
|
169
|
+
this.steering.add(task);
|
|
170
|
+
void task.finally(() => {
|
|
171
|
+
this.steering.delete(task);
|
|
172
|
+
void this.pump();
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
async interrupt() {
|
|
176
|
+
if (!this.turnRunning || this.interruptRequested)
|
|
177
|
+
return;
|
|
178
|
+
this.interruptRequested = true;
|
|
179
|
+
await this.driver.interrupt().catch(() => { });
|
|
180
|
+
}
|
|
181
|
+
/** Stops the agent and reports the session as closed, so the server can resume it later or finish ending it. */
|
|
182
|
+
close(error) {
|
|
183
|
+
return this.finish(error);
|
|
184
|
+
}
|
|
185
|
+
/** Stops the agent without reporting, because the server no longer accepts this lease. */
|
|
186
|
+
stop() {
|
|
187
|
+
this.closedReported = true;
|
|
188
|
+
return this.finish();
|
|
189
|
+
}
|
|
190
|
+
async pump() {
|
|
191
|
+
// While a steer is still being placed it decides what runs next; it pumps again once it knows.
|
|
192
|
+
if (this.turnRunning || this.closing || this.steering.size > 0)
|
|
193
|
+
return;
|
|
194
|
+
const message = this.pending.shift();
|
|
195
|
+
if (!message) {
|
|
196
|
+
this.armIdle();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
this.clearIdle();
|
|
200
|
+
this.turnRunning = true;
|
|
201
|
+
this.interruptRequested = false;
|
|
202
|
+
const started = this.now();
|
|
203
|
+
if (!this.openSucceeded) {
|
|
204
|
+
try {
|
|
205
|
+
this.opened ??= this.driver.open();
|
|
206
|
+
await this.opened;
|
|
207
|
+
this.openSucceeded = true;
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
const reason = errorMessage(error);
|
|
211
|
+
// A failed resume uses up the message as a failed turn, so the server does not ask for the same start again.
|
|
212
|
+
// A first start that fails has nothing to resume; the server fails the whole session.
|
|
213
|
+
if (this.options.start.resumeThreadId) {
|
|
214
|
+
this.events.push({ type: 'turnStarted', messageId: message.id });
|
|
215
|
+
this.events.push({ type: 'turnCompleted', status: 'failed', error: reason });
|
|
216
|
+
}
|
|
217
|
+
this.turnRunning = false;
|
|
218
|
+
await this.finish(reason);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (this.closing)
|
|
223
|
+
return;
|
|
224
|
+
this.events.push({ type: 'turnStarted', messageId: message.id });
|
|
225
|
+
let outcome;
|
|
226
|
+
try {
|
|
227
|
+
outcome = await this.withTimeout(this.driver.turn(message));
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
outcome = { status: 'failed', error: errorMessage(error) };
|
|
231
|
+
}
|
|
232
|
+
if (this.closing)
|
|
233
|
+
return;
|
|
234
|
+
if (this.interruptRequested && outcome.status === 'failed' && !outcome.error)
|
|
235
|
+
outcome = { ...outcome, status: 'interrupted' };
|
|
236
|
+
this.events.push({ type: 'turnCompleted', status: outcome.status, error: outcome.error,
|
|
237
|
+
stats: { ...outcome.stats, durationMs: this.now() - started } });
|
|
238
|
+
this.turnRunning = false;
|
|
239
|
+
void this.events.flush();
|
|
240
|
+
void this.pump();
|
|
241
|
+
}
|
|
242
|
+
/** Interrupts a turn that runs past its timeout, and gives up on an agent that does not stop when asked. */
|
|
243
|
+
async withTimeout(turn) {
|
|
244
|
+
const timeoutMs = Math.max(1, this.options.start.turnTimeoutSeconds) * 1000;
|
|
245
|
+
let timer;
|
|
246
|
+
let expired = false;
|
|
247
|
+
const timedOut = new Promise(resolve => {
|
|
248
|
+
timer = setTimeout(async () => {
|
|
249
|
+
expired = true;
|
|
250
|
+
await this.interrupt();
|
|
251
|
+
const stopped = await Promise.race([turn.then(() => true), delay(15_000).then(() => false)]);
|
|
252
|
+
resolve(stopped ? { status: 'interrupted', error: `The turn did not finish within ${this.options.start.turnTimeoutSeconds} seconds.` }
|
|
253
|
+
: { status: 'failed', error: `The agent did not stop after ${this.options.start.turnTimeoutSeconds} seconds.` });
|
|
254
|
+
if (!stopped)
|
|
255
|
+
void this.finish(`The agent did not stop after ${this.options.start.turnTimeoutSeconds} seconds.`);
|
|
256
|
+
}, timeoutMs);
|
|
257
|
+
});
|
|
258
|
+
try {
|
|
259
|
+
const outcome = await Promise.race([turn, timedOut]);
|
|
260
|
+
// The interrupt usually ends the turn before the timeout path does; the outcome should still say why it stopped.
|
|
261
|
+
return expired && outcome.status !== 'completed' && !outcome.error
|
|
262
|
+
? { ...outcome, error: `The turn did not finish within ${this.options.start.turnTimeoutSeconds} seconds.` } : outcome;
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
armIdle() {
|
|
269
|
+
this.clearIdle();
|
|
270
|
+
if (this.closing)
|
|
271
|
+
return;
|
|
272
|
+
this.idleTimer = setTimeout(() => void this.close(), this.options.idleMs ?? 15 * 60_000);
|
|
273
|
+
}
|
|
274
|
+
clearIdle() {
|
|
275
|
+
if (this.idleTimer)
|
|
276
|
+
clearTimeout(this.idleTimer);
|
|
277
|
+
this.idleTimer = null;
|
|
278
|
+
}
|
|
279
|
+
finish(error) {
|
|
280
|
+
this.closing ??= (async () => {
|
|
281
|
+
this.clearIdle();
|
|
282
|
+
await this.driver.close().catch(() => { });
|
|
283
|
+
if (this.turnRunning && !this.closedReported) {
|
|
284
|
+
this.events.push({ type: 'turnCompleted', status: 'failed', error: error ?? 'The agent was stopped.' });
|
|
285
|
+
this.turnRunning = false;
|
|
286
|
+
}
|
|
287
|
+
if (!this.closedReported) {
|
|
288
|
+
this.closedReported = true;
|
|
289
|
+
this.events.push({ type: 'closed', error });
|
|
290
|
+
}
|
|
291
|
+
await this.events.flush();
|
|
292
|
+
this.resolveDone();
|
|
293
|
+
})();
|
|
294
|
+
return this.closing;
|
|
295
|
+
}
|
|
296
|
+
}
|
package/dist/agents.js
CHANGED
|
@@ -3,8 +3,10 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { hostname, platform } from 'node:os';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import { createClaudeDriver } from './agents-claude.js';
|
|
7
|
+
import { createCodexDriver } from './agents-codex.js';
|
|
6
8
|
import { detectAgents, profilesPath } from './agents-detect.js';
|
|
7
|
-
import {
|
|
9
|
+
import { SessionRunner } from './agents-session.js';
|
|
8
10
|
import { readCliVersion } from './help.js';
|
|
9
11
|
const root = '/api/agents';
|
|
10
12
|
const PROFILE_HINT = 'Declare named profiles in ' + profilesPath() + ' to set a working directory or extra arguments.';
|
|
@@ -42,7 +44,7 @@ export async function loadListenerId(appDirectory, baseUrl, userId) {
|
|
|
42
44
|
await writeFile(file, JSON.stringify({ id }, null, 2), { mode: 0o600 });
|
|
43
45
|
return id;
|
|
44
46
|
}
|
|
45
|
-
export async function runAgentsCommand(client, args, flags, appDirectory) {
|
|
47
|
+
export async function runAgentsCommand(client, args, flags, appDirectory, teamsWatch) {
|
|
46
48
|
const action = args[0];
|
|
47
49
|
if (action === 'status') {
|
|
48
50
|
const detected = await detectAgents();
|
|
@@ -71,10 +73,18 @@ export async function runAgentsCommand(client, args, flags, appDirectory) {
|
|
|
71
73
|
console.log(sandbox
|
|
72
74
|
? 'Sandboxed: agents may read files, search and use the Skillshare MCP tools, but cannot change files or run commands that modify anything.'
|
|
73
75
|
: 'Agents run with your full local permissions and configuration, without permission prompts. Use --sandbox to keep them read-only.');
|
|
76
|
+
console.log('Sessions keep their agent running between messages and close it after 15 quiet minutes; the next message resumes it.');
|
|
77
|
+
// Teams 👀 sessions need a live Teams session, so the listener keeps it renewed unless told not to.
|
|
78
|
+
const teams = teamsWatch && flags['no-teams-watch'] !== 'true'
|
|
79
|
+
? teamsWatch(abort.signal, message => console.log(`[teams] ${message}`))
|
|
80
|
+
.catch(error => console.log(`[teams] Teams renewal stopped: ${error instanceof Error ? error.message : String(error)}`))
|
|
81
|
+
: Promise.resolve();
|
|
74
82
|
try {
|
|
75
83
|
await listen({ client, listenerId, name: flags.name, workdir, maxConcurrency: max, sandbox, signal: abort.signal, onProgress: console.log });
|
|
76
84
|
}
|
|
77
85
|
finally {
|
|
86
|
+
abort.abort();
|
|
87
|
+
await teams;
|
|
78
88
|
process.removeListener('SIGINT', stop);
|
|
79
89
|
process.removeListener('SIGTERM', stop);
|
|
80
90
|
}
|
|
@@ -100,11 +110,13 @@ export function renderDetected(listenerId, server, detected) {
|
|
|
100
110
|
}
|
|
101
111
|
export async function listen(options) {
|
|
102
112
|
const detect = options.detect ?? (() => detectAgents());
|
|
103
|
-
const
|
|
113
|
+
const codex = createCodexDriver({ clientVersion: readCliVersion() });
|
|
114
|
+
const claude = createClaudeDriver();
|
|
115
|
+
const driver = options.driver ?? (start => start.configuration.agent === 'codex' ? codex : claude);
|
|
104
116
|
const wait = options.wait ?? (async (ms, signal) => { await delay(ms, undefined, { signal }); });
|
|
105
117
|
const now = options.now ?? Date.now;
|
|
106
118
|
const log = options.onProgress ?? (() => { });
|
|
107
|
-
const
|
|
119
|
+
const sessions = new Map();
|
|
108
120
|
let detected = await detect();
|
|
109
121
|
let registeredFingerprint = '';
|
|
110
122
|
let detectedAt = now();
|
|
@@ -119,48 +131,52 @@ export async function listen(options) {
|
|
|
119
131
|
registeredFingerprint = fingerprint;
|
|
120
132
|
for (const problem of detected.problems)
|
|
121
133
|
log(`! ${problem}`);
|
|
122
|
-
log(`Listening as "${registered.name}" with ${registered.effectiveConcurrency} slot(s): ${describe(detected)}.
|
|
134
|
+
log(`Listening as "${registered.name}" with ${registered.effectiveConcurrency} slot(s): ${describe(detected)}. Start sessions and choose routes under Connected agents on ${options.client.baseUrl}.`);
|
|
123
135
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
: { leaseToken: assignment.leaseToken, reason: result.reason, cancelled: result.cancelled, stats: result.stats };
|
|
128
|
-
for (let attempt = 0; attempt < 5; attempt++) {
|
|
136
|
+
/** Posts a batch of session events: a rejected lease means the server moved on and the agent must stop. */
|
|
137
|
+
function uploader(sessionId, leaseToken) {
|
|
138
|
+
return async (events) => {
|
|
129
139
|
try {
|
|
130
|
-
await send(options.client, 'POST',
|
|
131
|
-
return;
|
|
140
|
+
await send(options.client, 'POST', `${root}/sessions/${sessionId}/events`, { leaseToken, events });
|
|
141
|
+
return 'ok';
|
|
132
142
|
}
|
|
133
143
|
catch (error) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
if (attempt < 4)
|
|
140
|
-
await delay(2000 * (attempt + 1)).catch(() => { });
|
|
144
|
+
if (error instanceof AgentsHttpError && error.status >= 400 && error.status < 500)
|
|
145
|
+
return 'gone';
|
|
146
|
+
return 'retry';
|
|
141
147
|
}
|
|
142
|
-
}
|
|
143
|
-
log(`Task ${assignment.taskId} result could not be delivered; the server will retry the task after its lease expires.`);
|
|
148
|
+
};
|
|
144
149
|
}
|
|
145
|
-
function start(
|
|
146
|
-
if (
|
|
150
|
+
function start(command) {
|
|
151
|
+
if (!command.start || !command.leaseToken)
|
|
147
152
|
return;
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
153
|
+
// A start for a session already held replaces its agent, for example to hand it fresh credentials.
|
|
154
|
+
const previous = sessions.get(command.sessionId);
|
|
155
|
+
if (previous)
|
|
156
|
+
void previous.stop();
|
|
157
|
+
const profile = command.start.configuration.profile ? detected.profiles.find(p => p.name === command.start.configuration.profile) : undefined;
|
|
158
|
+
const runner = new SessionRunner({
|
|
159
|
+
sessionId: command.sessionId, leaseToken: command.leaseToken, start: command.start, workdir: options.workdir, profile, sandbox: options.sandbox === true,
|
|
160
|
+
driver: driver(command.start), upload: uploader(command.sessionId, command.leaseToken), idleMs: options.idleMs, flushIntervalMs: options.flushIntervalMs,
|
|
161
|
+
now,
|
|
162
|
+
});
|
|
163
|
+
sessions.set(command.sessionId, runner);
|
|
164
|
+
log(`${command.start.resumeThreadId ? 'Resumed' : 'Started'} session ${command.sessionId} "${command.start.title}" with ${label(command.start)}.`);
|
|
165
|
+
void runner.done.then(() => {
|
|
166
|
+
if (sessions.get(command.sessionId) === runner)
|
|
167
|
+
sessions.delete(command.sessionId);
|
|
168
|
+
log(`Closed session ${command.sessionId}.`);
|
|
169
|
+
});
|
|
170
|
+
runner.send(command.messages ?? []);
|
|
171
|
+
}
|
|
172
|
+
function handle(command) {
|
|
173
|
+
const runner = sessions.get(command.sessionId);
|
|
174
|
+
switch (command.type) {
|
|
175
|
+
case 'start': return start(command);
|
|
176
|
+
case 'send': return runner?.send(command.messages ?? []);
|
|
177
|
+
case 'interrupt': return void runner?.interrupt();
|
|
178
|
+
case 'close': return void runner?.close();
|
|
179
|
+
}
|
|
164
180
|
}
|
|
165
181
|
try {
|
|
166
182
|
while (!options.signal.aborted) {
|
|
@@ -173,17 +189,14 @@ export async function listen(options) {
|
|
|
173
189
|
await register();
|
|
174
190
|
const polledAt = now();
|
|
175
191
|
const response = await send(options.client, 'POST', `${root}/listeners/${options.listenerId}/poll`, {
|
|
176
|
-
// A
|
|
177
|
-
|
|
178
|
-
.map(r => ({ taskId: Number(r.assignment.taskId), leaseToken: r.assignment.leaseToken })),
|
|
192
|
+
// A session that is already closing is not reported, so the server treats its agent as gone.
|
|
193
|
+
sessions: [...sessions.values()].filter(r => !r.stopping).map(r => ({ sessionId: r.sessionId, leaseToken: r.leaseToken })),
|
|
179
194
|
waitSeconds: options.pollWaitSeconds ?? 25,
|
|
180
195
|
}, options.signal);
|
|
181
196
|
failures = 0;
|
|
182
|
-
for (const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
start(assignment);
|
|
186
|
-
if (response.assignments.length === 0 && now() - polledAt < 1000)
|
|
197
|
+
for (const command of response.commands)
|
|
198
|
+
handle(command);
|
|
199
|
+
if (response.commands.length === 0 && now() - polledAt < 1000)
|
|
187
200
|
await wait(1000, options.signal).catch(() => { });
|
|
188
201
|
}
|
|
189
202
|
catch (error) {
|
|
@@ -206,13 +219,12 @@ export async function listen(options) {
|
|
|
206
219
|
}
|
|
207
220
|
}
|
|
208
221
|
finally {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
await Promise.all([...running.values()].map(task => task.done));
|
|
222
|
+
// Closing reports each session as closed, so the server knows at once that the next message must resume it.
|
|
223
|
+
await Promise.all([...sessions.values()].map(runner => runner.close()));
|
|
212
224
|
}
|
|
213
225
|
}
|
|
214
|
-
function label(
|
|
215
|
-
const { configuration } =
|
|
226
|
+
function label(start) {
|
|
227
|
+
const { configuration } = start;
|
|
216
228
|
if (configuration.profile)
|
|
217
229
|
return `profile ${configuration.profile}`;
|
|
218
230
|
return [configuration.agent === 'claude' ? 'Claude Code' : 'Codex', configuration.model, configuration.reasoning]
|