@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.
@@ -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
- 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) : [];
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('No Codex model list found in models_cache.json. Run codex once to refresh it; only the default model is offered.');
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 { runAgent } from './agents-run.js';
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 run = options.run ?? runAgent;
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 running = new Map();
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)}. Choose what runs where under Connected agents on ${options.client.baseUrl}.`);
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
- async function report(assignment, result) {
125
- const path = `${root}/tasks/${assignment.taskId}/${result.ok ? 'result' : 'failure'}`;
126
- const body = result.ok ? { leaseToken: assignment.leaseToken, markdown: result.markdown, stats: result.stats }
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', path, body);
131
- return;
140
+ await send(options.client, 'POST', `${root}/sessions/${sessionId}/events`, { leaseToken, events });
141
+ return 'ok';
132
142
  }
133
143
  catch (error) {
134
- // A rejected lease means the server already moved on; retrying cannot help.
135
- if (error instanceof AgentsHttpError && error.status >= 400 && error.status < 500) {
136
- log(`Task ${assignment.taskId} result was not accepted: ${error.message}`);
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(assignment) {
146
- if (running.has(assignment.taskId))
150
+ function start(command) {
151
+ if (!command.start || !command.leaseToken)
147
152
  return;
148
- const abort = new AbortController();
149
- const profile = assignment.configuration.profile ? detected.profiles.find(p => p.name === assignment.configuration.profile) : undefined;
150
- log(`Started task ${assignment.taskId} "${assignment.title}" with ${label(assignment)}.`);
151
- const done = (async () => {
152
- let result;
153
- try {
154
- result = await run(assignment, { workdir: options.workdir, profile, sandbox: options.sandbox, signal: abort.signal });
155
- }
156
- catch (error) {
157
- result = { ok: false, reason: `The listener could not run the agent: ${error instanceof Error ? error.message : String(error)}`, cancelled: false, stats: {} };
158
- }
159
- log(result.ok ? `Finished task ${assignment.taskId}.` : `${result.cancelled ? 'Cancelled' : 'Failed'} task ${assignment.taskId}: ${result.reason}`);
160
- await report(assignment, result);
161
- running.delete(assignment.taskId);
162
- })();
163
- running.set(assignment.taskId, { assignment, abort, done });
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 task that is already stopping is not reported, otherwise the server would answer every poll with its cancellation.
177
- running: [...running.values()].filter(r => !r.abort.signal.aborted)
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 id of response.cancel)
183
- running.get(id)?.abort.abort();
184
- for (const assignment of response.assignments)
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
- for (const task of running.values())
210
- task.abort.abort();
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(assignment) {
215
- const { configuration } = assignment;
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]