@quadient/skillshare 1.9.0 โ 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 +12 -6
- package/dist/agents-claude.js +274 -0
- package/dist/agents-codex.js +276 -0
- package/dist/agents-process.js +50 -0
- package/dist/agents-session.js +267 -0
- package/dist/agents.js +56 -51
- package/dist/help.js +1 -1
- package/package.json +1 -1
- package/dist/agents-run.js +0 -174
package/README.md
CHANGED
|
@@ -21,12 +21,17 @@ bun run index.ts install --skill-version-id 42 --target ./installed
|
|
|
21
21
|
|
|
22
22
|
## Connected agents
|
|
23
23
|
|
|
24
|
-
`skillshare agents listen` lets Skillshare run Claude Code (`claude`) or Codex (`codex`)
|
|
25
|
-
on this computer.
|
|
26
|
-
|
|
27
|
-
agents
|
|
24
|
+
`skillshare agents listen` lets Skillshare run Claude Code (`claude`) or Codex (`codex`) sessions
|
|
25
|
+
on this computer. Start a session under **Sessions** in Skillshare and keep talking to the agent
|
|
26
|
+
there; Teams ๐ sessions go to the computer chosen under **Connected agents**. The listener
|
|
27
|
+
reports the installed agents with their models and reasoning levels. Agents use the login you
|
|
28
|
+
already have in `claude` and `codex`, and run with your full local permissions and configuration,
|
|
28
29
|
without permission prompts.
|
|
29
30
|
|
|
31
|
+
Codex runs as `codex app-server` and Claude Code with stream-json input and output. The agent
|
|
32
|
+
keeps running between your messages and stops after 15 quiet minutes; the next message resumes
|
|
33
|
+
the same Codex thread or Claude Code session. Sessions need CLI 1.10.0 or newer.
|
|
34
|
+
|
|
30
35
|
```sh
|
|
31
36
|
skillshare agents status
|
|
32
37
|
skillshare agents listen --workdir ~/Research
|
|
@@ -38,7 +43,8 @@ and use the Skillshare MCP; Codex runs in its read-only sandbox. A profile can d
|
|
|
38
43
|
with `"sandbox": true`.
|
|
39
44
|
|
|
40
45
|
Optional named profiles in `~/.config/skillshare/agents.json` set a working directory and
|
|
41
|
-
extra arguments.
|
|
46
|
+
extra arguments. For Claude Code the arguments are added to `claude`; for Codex only
|
|
47
|
+
configuration overrides (`-c`, `--config`, `--enable`, `--disable`) apply to `codex app-server`. Only the name, agent, model, reasoning and the working directory's last
|
|
42
48
|
segment are sent to Skillshare.
|
|
43
49
|
|
|
44
50
|
```json
|
|
@@ -85,7 +91,7 @@ permissions, Microsoft session lifetime and file-tool differences in the hosted
|
|
|
85
91
|
|
|
86
92
|
Starting with 1.9.0:
|
|
87
93
|
|
|
88
|
-
- `agents listen` and `agents status` run Claude Code and Codex
|
|
94
|
+
- `agents listen` and `agents status` run Claude Code and Codex sessions from Skillshare on this computer (see [Connected agents](#connected-agents)); `--sandbox` keeps them read-only.
|
|
89
95
|
- Commands print readable text. Scripts that parsed JSON from `whoami`, `list`, `show`, `upload`, `pages list|show|runtime|upload` or `teams status|test|upload` must add `--json`; with `--json` progress goes to stderr and stdout contains only JSON.
|
|
90
96
|
- `show` accepts a skill name as well as an id.
|
|
91
97
|
- `pages upload` refuses build output folder names such as `dist` or `build` without `--slug`, says whether it creates or replaces a page, refuses pages owned by someone else unless you are an admin, and prints the page URL.
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { startAgentProcess } from './agents-process.js';
|
|
6
|
+
import { CLAUDE_SANDBOX_TOOLS, DEFAULT, SANDBOX_NOTE, clip, isSandboxed, mcpServerName, mcpTokenEnvironment, oneLine, } from './agents-session.js';
|
|
7
|
+
/**
|
|
8
|
+
* Claude Code in print mode with stream-json in both directions: the same protocol the Agent SDK speaks, but against the
|
|
9
|
+
* user's own claude install, so their login and settings apply. MCP config and the session's instructions are passed per
|
|
10
|
+
* process; tokens only through the environment.
|
|
11
|
+
*/
|
|
12
|
+
export function claudeArgs(context, mcpConfigPath) {
|
|
13
|
+
const { start } = context;
|
|
14
|
+
const sandbox = isSandboxed(start.configuration, context);
|
|
15
|
+
const servers = start.mcpServers ?? [];
|
|
16
|
+
const args = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--include-partial-messages',
|
|
17
|
+
...(sandbox
|
|
18
|
+
// A sandboxed session never prompts: anything outside the read-only allowlist is denied.
|
|
19
|
+
? ['--permission-mode', 'dontAsk', '--allowedTools', ...CLAUDE_SANDBOX_TOOLS, ...servers.map((server, index) => `mcp__${mcpServerName(server, index)}`)]
|
|
20
|
+
: ['--dangerously-skip-permissions'])];
|
|
21
|
+
if (start.configuration.model !== DEFAULT)
|
|
22
|
+
args.push('--model', start.configuration.model);
|
|
23
|
+
if (start.configuration.reasoning !== DEFAULT)
|
|
24
|
+
args.push('--effort', start.configuration.reasoning);
|
|
25
|
+
if (mcpConfigPath)
|
|
26
|
+
args.push('--mcp-config', mcpConfigPath);
|
|
27
|
+
const instructions = [start.instructions?.trim(), sandbox ? SANDBOX_NOTE : undefined].filter(Boolean).join('\n\n');
|
|
28
|
+
if (instructions)
|
|
29
|
+
args.push('--append-system-prompt', instructions);
|
|
30
|
+
if (start.resumeThreadId)
|
|
31
|
+
args.push('--resume', start.resumeThreadId);
|
|
32
|
+
return [...args, ...(context.profile?.args ?? [])];
|
|
33
|
+
}
|
|
34
|
+
export function claudeMcpConfig(servers) {
|
|
35
|
+
return JSON.stringify({
|
|
36
|
+
mcpServers: Object.fromEntries(servers.map((server, index) => [mcpServerName(server, index),
|
|
37
|
+
{ type: 'http', url: server.url, headers: { Authorization: `Bearer \${SKILLSHARE_MCP_TOKEN_${index}}` } }])),
|
|
38
|
+
}, null, 2);
|
|
39
|
+
}
|
|
40
|
+
/** A tool call as a transcript entry: shell commands and file edits get their own kinds, everything else is a tool. */
|
|
41
|
+
export function claudeToolItem(block) {
|
|
42
|
+
const input = block.input ?? {};
|
|
43
|
+
const name = String(block.name ?? 'tool');
|
|
44
|
+
const key = String(block.id);
|
|
45
|
+
const text = (value) => typeof value === 'string' ? value : '';
|
|
46
|
+
switch (name) {
|
|
47
|
+
case 'Bash': return { key, kind: 'command', status: 'inProgress', title: oneLine(text(input.command)), detail: text(input.description) || undefined };
|
|
48
|
+
case 'Edit':
|
|
49
|
+
case 'MultiEdit':
|
|
50
|
+
case 'Write':
|
|
51
|
+
return { key, kind: 'fileChange', status: 'inProgress', title: text(input.file_path) };
|
|
52
|
+
case 'NotebookEdit': return { key, kind: 'fileChange', status: 'inProgress', title: text(input.notebook_path) };
|
|
53
|
+
case 'TodoWrite': {
|
|
54
|
+
const todos = Array.isArray(input.todos) ? input.todos : [];
|
|
55
|
+
return { key, kind: 'plan', status: 'completed',
|
|
56
|
+
text: clip(todos.map((todo) => `- [${todo.status === 'completed' ? 'x' : ' '}] ${todo.content ?? ''}`).join('\n')) };
|
|
57
|
+
}
|
|
58
|
+
case 'Task':
|
|
59
|
+
case 'Agent': return { key, kind: 'tool', status: 'inProgress', title: oneLine(`Subagent: ${text(input.description) || text(input.subagent_type)}`) };
|
|
60
|
+
}
|
|
61
|
+
const mcp = /^mcp__(.+?)__(.+)$/.exec(name);
|
|
62
|
+
if (mcp)
|
|
63
|
+
return { key, kind: 'tool', status: 'inProgress', title: `${mcp[1]} ยท ${mcp[2]}` };
|
|
64
|
+
const subject = text(input.file_path) || text(input.pattern) || text(input.url) || text(input.query) || text(input.path);
|
|
65
|
+
return { key, kind: 'tool', status: 'inProgress', title: oneLine(subject ? `${name} ${subject}` : name) };
|
|
66
|
+
}
|
|
67
|
+
export function createClaudeDriver(options = {}) {
|
|
68
|
+
return context => new ClaudeDriver(context, options);
|
|
69
|
+
}
|
|
70
|
+
class ClaudeDriver {
|
|
71
|
+
context;
|
|
72
|
+
options;
|
|
73
|
+
process = null;
|
|
74
|
+
directory = null;
|
|
75
|
+
sessionId = null;
|
|
76
|
+
current = null;
|
|
77
|
+
controls = new Map();
|
|
78
|
+
/** Text and tool entries by key, so later frames can update an entry the transcript already shows. */
|
|
79
|
+
items = new Map();
|
|
80
|
+
blocks = new Map();
|
|
81
|
+
streamed = new Map();
|
|
82
|
+
messageId = '';
|
|
83
|
+
costSoFar = 0;
|
|
84
|
+
closing = false;
|
|
85
|
+
constructor(context, options) {
|
|
86
|
+
this.context = context;
|
|
87
|
+
this.options = options;
|
|
88
|
+
}
|
|
89
|
+
async open() {
|
|
90
|
+
const servers = this.context.start.mcpServers ?? [];
|
|
91
|
+
let mcpConfig;
|
|
92
|
+
if (servers.length > 0) {
|
|
93
|
+
this.directory = await mkdtemp(join(tmpdir(), 'skillshare-session-'));
|
|
94
|
+
mcpConfig = join(this.directory, 'mcp.json');
|
|
95
|
+
await writeFile(mcpConfig, claudeMcpConfig(servers), { mode: 0o600 });
|
|
96
|
+
}
|
|
97
|
+
this.process = startAgentProcess({
|
|
98
|
+
command: this.options.command ?? 'claude',
|
|
99
|
+
args: claudeArgs(this.context, mcpConfig),
|
|
100
|
+
cwd: this.context.profile?.workdir ?? this.context.workdir,
|
|
101
|
+
env: {
|
|
102
|
+
...mcpTokenEnvironment(servers), SKILLSHARE_SESSION_ID: this.context.sessionId,
|
|
103
|
+
// Claude Code lets auto-memory write to its own folder even under the read-only allowlist; a sandboxed session writes nothing.
|
|
104
|
+
...(isSandboxed(this.context.start.configuration, this.context) ? { CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1' } : {}),
|
|
105
|
+
},
|
|
106
|
+
spawnAgent: this.options.spawnAgent,
|
|
107
|
+
onLine: line => this.receive(line),
|
|
108
|
+
});
|
|
109
|
+
void this.process.exited.then(code => this.onExit(code));
|
|
110
|
+
// Claude Code answers initialize once it is ready, which turns a bad install or a bad flag into a clear start failure.
|
|
111
|
+
await this.control({ subtype: 'initialize' });
|
|
112
|
+
if (this.context.start.resumeThreadId)
|
|
113
|
+
this.adopt(this.context.start.resumeThreadId);
|
|
114
|
+
}
|
|
115
|
+
turn(message) {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
this.current = { resolve, interrupted: false };
|
|
118
|
+
const sent = this.process?.write(JSON.stringify({
|
|
119
|
+
type: 'user', message: { role: 'user', content: message.text }, parent_tool_use_id: null, session_id: this.sessionId ?? '',
|
|
120
|
+
}));
|
|
121
|
+
if (!sent) {
|
|
122
|
+
this.current = null;
|
|
123
|
+
reject(new Error('Claude Code is not accepting input.'));
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
async interrupt() {
|
|
128
|
+
if (!this.current)
|
|
129
|
+
return;
|
|
130
|
+
this.current.interrupted = true;
|
|
131
|
+
await this.control({ subtype: 'interrupt' }).catch(() => { });
|
|
132
|
+
}
|
|
133
|
+
async close() {
|
|
134
|
+
this.closing = true;
|
|
135
|
+
await this.process?.kill();
|
|
136
|
+
if (this.directory)
|
|
137
|
+
await rm(this.directory, { recursive: true, force: true }).catch(() => { });
|
|
138
|
+
}
|
|
139
|
+
control(request) {
|
|
140
|
+
const requestId = randomUUID();
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
this.controls.set(requestId, { resolve, reject });
|
|
143
|
+
if (!this.process?.write(JSON.stringify({ type: 'control_request', request_id: requestId, request }))) {
|
|
144
|
+
this.controls.delete(requestId);
|
|
145
|
+
reject(new Error('Claude Code is not accepting input.'));
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
adopt(sessionId) {
|
|
150
|
+
if (sessionId === this.sessionId)
|
|
151
|
+
return;
|
|
152
|
+
this.sessionId = sessionId;
|
|
153
|
+
this.context.thread(sessionId);
|
|
154
|
+
}
|
|
155
|
+
emit(item) {
|
|
156
|
+
this.items.set(item.key, item);
|
|
157
|
+
this.context.item(item);
|
|
158
|
+
}
|
|
159
|
+
receive(line) {
|
|
160
|
+
let frame;
|
|
161
|
+
try {
|
|
162
|
+
frame = JSON.parse(line);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
switch (frame.type) {
|
|
168
|
+
case 'control_response': {
|
|
169
|
+
const response = frame.response ?? {};
|
|
170
|
+
const waiter = this.controls.get(response.request_id);
|
|
171
|
+
if (!waiter)
|
|
172
|
+
return;
|
|
173
|
+
this.controls.delete(response.request_id);
|
|
174
|
+
if (response.subtype === 'error')
|
|
175
|
+
waiter.reject(new Error(String(response.error ?? 'Claude Code rejected the request.')));
|
|
176
|
+
else
|
|
177
|
+
waiter.resolve(response.response ?? {});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
case 'control_request':
|
|
181
|
+
// Permission prompts never reach a session: it runs without prompts or with a read-only allowlist.
|
|
182
|
+
this.process?.write(JSON.stringify({ type: 'control_response',
|
|
183
|
+
response: { subtype: 'error', request_id: frame.request_id, error: 'Skillshare sessions do not answer this request.' } }));
|
|
184
|
+
return;
|
|
185
|
+
case 'system':
|
|
186
|
+
if (frame.subtype === 'init' && typeof frame.session_id === 'string')
|
|
187
|
+
this.adopt(frame.session_id);
|
|
188
|
+
return;
|
|
189
|
+
case 'stream_event':
|
|
190
|
+
return this.streamEvent(frame.event ?? {});
|
|
191
|
+
case 'assistant':
|
|
192
|
+
return this.assistant(frame.message ?? {});
|
|
193
|
+
case 'user':
|
|
194
|
+
return this.toolResults(frame.message?.content);
|
|
195
|
+
case 'result':
|
|
196
|
+
return this.result(frame);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Text arrives in deltas first; the complete assistant frame later replaces it under the same key. */
|
|
200
|
+
streamEvent(event) {
|
|
201
|
+
if (event.type === 'message_start' && typeof event.message?.id === 'string')
|
|
202
|
+
this.messageId = event.message.id;
|
|
203
|
+
if (event.type !== 'content_block_delta' || !this.messageId)
|
|
204
|
+
return;
|
|
205
|
+
const delta = event.delta ?? {};
|
|
206
|
+
const kind = delta.type === 'text_delta' ? 'assistant' : delta.type === 'thinking_delta' ? 'reasoning' : null;
|
|
207
|
+
if (!kind)
|
|
208
|
+
return;
|
|
209
|
+
const key = `${this.messageId}:${event.index}`;
|
|
210
|
+
const text = (this.streamed.get(key) ?? '') + String(delta.text ?? delta.thinking ?? '');
|
|
211
|
+
this.streamed.set(key, text);
|
|
212
|
+
if (text.trim())
|
|
213
|
+
this.emit({ key, kind, status: 'inProgress', text });
|
|
214
|
+
}
|
|
215
|
+
assistant(message) {
|
|
216
|
+
const id = String(message.id ?? '');
|
|
217
|
+
for (const block of Array.isArray(message.content) ? message.content : []) {
|
|
218
|
+
const index = this.blocks.get(id) ?? 0;
|
|
219
|
+
this.blocks.set(id, index + 1);
|
|
220
|
+
const key = `${id}:${index}`;
|
|
221
|
+
if (block.type === 'text' && typeof block.text === 'string')
|
|
222
|
+
this.emit({ key, kind: 'assistant', status: 'completed', text: block.text });
|
|
223
|
+
else if (block.type === 'thinking' && typeof block.thinking === 'string' && block.thinking.trim())
|
|
224
|
+
this.emit({ key, kind: 'reasoning', status: 'completed', text: block.thinking });
|
|
225
|
+
else if (block.type === 'tool_use' && block.id)
|
|
226
|
+
this.emit(claudeToolItem(block));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
toolResults(content) {
|
|
230
|
+
if (!Array.isArray(content))
|
|
231
|
+
return;
|
|
232
|
+
for (const block of content) {
|
|
233
|
+
if (block?.type !== 'tool_result')
|
|
234
|
+
continue;
|
|
235
|
+
const item = this.items.get(String(block.tool_use_id));
|
|
236
|
+
if (item && item.status === 'inProgress')
|
|
237
|
+
this.emit({ ...item, status: block.is_error ? 'failed' : 'completed' });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
result(frame) {
|
|
241
|
+
if (typeof frame.session_id === 'string')
|
|
242
|
+
this.adopt(frame.session_id);
|
|
243
|
+
const current = this.current;
|
|
244
|
+
this.current = null;
|
|
245
|
+
if (!current)
|
|
246
|
+
return;
|
|
247
|
+
// Costs are totals for the process; a turn's share is the difference.
|
|
248
|
+
const cost = typeof frame.total_cost_usd === 'number' ? frame.total_cost_usd : undefined;
|
|
249
|
+
const turnCost = cost === undefined ? undefined : Math.max(0, cost - this.costSoFar);
|
|
250
|
+
if (cost !== undefined)
|
|
251
|
+
this.costSoFar = cost;
|
|
252
|
+
const usage = frame.usage ?? {};
|
|
253
|
+
const input = [usage.input_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens].filter((n) => typeof n === 'number');
|
|
254
|
+
const stats = { costUsd: turnCost, inputTokens: input.length > 0 ? input.reduce((a, b) => a + b, 0) : undefined,
|
|
255
|
+
outputTokens: typeof usage.output_tokens === 'number' ? usage.output_tokens : undefined };
|
|
256
|
+
if (frame.subtype === 'success' && frame.is_error !== true)
|
|
257
|
+
return current.resolve({ status: 'completed', stats });
|
|
258
|
+
if (current.interrupted)
|
|
259
|
+
return current.resolve({ status: 'interrupted', stats });
|
|
260
|
+
const detail = typeof frame.result === 'string' && frame.result ? frame.result
|
|
261
|
+
: Array.isArray(frame.errors) && frame.errors.length > 0 ? frame.errors.join(' ') : `Claude Code ended the turn with ${frame.subtype ?? 'an error'}.`;
|
|
262
|
+
current.resolve({ status: 'failed', error: clip(detail, 2000), stats });
|
|
263
|
+
}
|
|
264
|
+
onExit(code) {
|
|
265
|
+
const reason = `Claude Code exited${code === null ? '' : ` with code ${code}`}${this.process?.stderrTail() ? `: ${this.process.stderrTail()}` : '.'}`;
|
|
266
|
+
for (const waiter of this.controls.values())
|
|
267
|
+
waiter.reject(new Error(reason));
|
|
268
|
+
this.controls.clear();
|
|
269
|
+
this.current?.resolve({ status: 'failed', error: reason });
|
|
270
|
+
this.current = null;
|
|
271
|
+
if (!this.closing)
|
|
272
|
+
this.context.exited(reason);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { startAgentProcess } from './agents-process.js';
|
|
2
|
+
import { DEFAULT, SANDBOX_NOTE, clip, isSandboxed, mcpServerName, mcpTokenEnvironment, oneLine, } from './agents-session.js';
|
|
3
|
+
/** Profile arguments that make sense for a long-running app-server; anything else belongs to codex exec only. */
|
|
4
|
+
export function codexConfigArgs(args) {
|
|
5
|
+
const kept = [];
|
|
6
|
+
for (let index = 0; index < args.length; index++) {
|
|
7
|
+
const arg = args[index];
|
|
8
|
+
if (['-c', '--config', '--enable', '--disable'].includes(arg) && args[index + 1] !== undefined)
|
|
9
|
+
kept.push(arg, args[++index]);
|
|
10
|
+
else if (/^(-c|--config|--enable|--disable)=/.test(arg))
|
|
11
|
+
kept.push(arg);
|
|
12
|
+
}
|
|
13
|
+
return kept;
|
|
14
|
+
}
|
|
15
|
+
/** thread/start and thread/resume share these: where to work, what may run, the session's MCP servers and instructions. */
|
|
16
|
+
export function codexThreadParams(context) {
|
|
17
|
+
const { start } = context;
|
|
18
|
+
const sandbox = isSandboxed(start.configuration, context);
|
|
19
|
+
const servers = start.mcpServers ?? [];
|
|
20
|
+
const instructions = [start.instructions?.trim(), sandbox ? SANDBOX_NOTE : undefined].filter(Boolean).join('\n\n');
|
|
21
|
+
return {
|
|
22
|
+
cwd: context.profile?.workdir ?? context.workdir,
|
|
23
|
+
...(start.configuration.model !== DEFAULT ? { model: start.configuration.model } : {}),
|
|
24
|
+
// Nobody watches for approval prompts, so the agent never asks: full access, or a read-only sandbox.
|
|
25
|
+
approvalPolicy: 'never',
|
|
26
|
+
sandbox: sandbox ? 'read-only' : 'danger-full-access',
|
|
27
|
+
...(instructions ? { developerInstructions: instructions } : {}),
|
|
28
|
+
config: {
|
|
29
|
+
'tools.update_plan.enabled': true,
|
|
30
|
+
...(servers.length > 0 ? {
|
|
31
|
+
mcp_servers: Object.fromEntries(servers.map((server, index) => [mcpServerName(server, index), { url: server.url, bearer_token_env_var: `SKILLSHARE_MCP_TOKEN_${index}` }])),
|
|
32
|
+
} : {}),
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Codex errors often arrive as the provider's JSON body; the readable part is its message. */
|
|
37
|
+
export function codexErrorText(message) {
|
|
38
|
+
if (typeof message !== 'string')
|
|
39
|
+
return undefined;
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(message);
|
|
42
|
+
const nested = parsed?.error?.message ?? parsed?.message;
|
|
43
|
+
if (typeof nested === 'string' && nested)
|
|
44
|
+
return nested;
|
|
45
|
+
}
|
|
46
|
+
catch { }
|
|
47
|
+
return message;
|
|
48
|
+
}
|
|
49
|
+
const STATUS = { inProgress: 'inProgress', completed: 'completed', failed: 'failed', declined: 'failed' };
|
|
50
|
+
/** Turns one Codex thread item into a transcript entry; items the user already sees, such as their own message, are skipped. */
|
|
51
|
+
export function codexItem(item) {
|
|
52
|
+
const key = typeof item?.id === 'string' ? item.id : null;
|
|
53
|
+
if (!key)
|
|
54
|
+
return null;
|
|
55
|
+
const status = STATUS[item.status] ?? undefined;
|
|
56
|
+
switch (item.type) {
|
|
57
|
+
case 'agentMessage': return { key, kind: 'assistant', text: String(item.text ?? '') };
|
|
58
|
+
case 'reasoning': {
|
|
59
|
+
const summary = Array.isArray(item.summary) ? item.summary.join('\n\n') : '';
|
|
60
|
+
return summary ? { key, kind: 'reasoning', text: summary } : null;
|
|
61
|
+
}
|
|
62
|
+
case 'plan': return { key, kind: 'plan', text: String(item.text ?? '') };
|
|
63
|
+
case 'commandExecution':
|
|
64
|
+
return { key, kind: 'command', status, title: oneLine(String(item.command ?? '')),
|
|
65
|
+
detail: typeof item.exitCode === 'number' && item.exitCode !== 0 ? `exit code ${item.exitCode}` : undefined };
|
|
66
|
+
case 'fileChange': {
|
|
67
|
+
const paths = (Array.isArray(item.changes) ? item.changes : []).map((change) => String(change?.path ?? '')).filter(Boolean);
|
|
68
|
+
return { key, kind: 'fileChange', status, title: paths.slice(0, 5).join(', ') + (paths.length > 5 ? ` and ${paths.length - 5} more` : '') };
|
|
69
|
+
}
|
|
70
|
+
case 'mcpToolCall': return { key, kind: 'tool', status, title: `${item.server} ยท ${item.tool}` };
|
|
71
|
+
case 'dynamicToolCall': return { key, kind: 'tool', status, title: String(item.tool ?? 'tool') };
|
|
72
|
+
case 'webSearch': return { key, kind: 'tool', status: 'completed', title: oneLine(`Web search: ${item.query ?? ''}`) };
|
|
73
|
+
case 'collabAgentToolCall': return { key, kind: 'tool', status, title: `Subagent: ${item.tool}` };
|
|
74
|
+
case 'imageView': return { key, kind: 'tool', status: 'completed', title: oneLine(`View image ${item.path ?? ''}`) };
|
|
75
|
+
case 'imageGeneration': return { key, kind: 'tool', status, title: 'Generate image' };
|
|
76
|
+
case 'contextCompaction': return { key, kind: 'notice', text: 'Context compacted.' };
|
|
77
|
+
default: return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Codex through `codex app-server`: JSON-RPC over stdio, one app-server process and one thread per session. */
|
|
81
|
+
export function createCodexDriver(options = {}) {
|
|
82
|
+
return context => new CodexDriver(context, options);
|
|
83
|
+
}
|
|
84
|
+
class CodexDriver {
|
|
85
|
+
context;
|
|
86
|
+
options;
|
|
87
|
+
process = null;
|
|
88
|
+
nextId = 0;
|
|
89
|
+
pending = new Map();
|
|
90
|
+
threadId = null;
|
|
91
|
+
current = null;
|
|
92
|
+
texts = new Map();
|
|
93
|
+
closing = false;
|
|
94
|
+
constructor(context, options) {
|
|
95
|
+
this.context = context;
|
|
96
|
+
this.options = options;
|
|
97
|
+
}
|
|
98
|
+
async open() {
|
|
99
|
+
const { start } = this.context;
|
|
100
|
+
const servers = start.mcpServers ?? [];
|
|
101
|
+
this.process = startAgentProcess({
|
|
102
|
+
command: this.options.command ?? 'codex',
|
|
103
|
+
args: ['app-server', ...codexConfigArgs(this.context.profile?.args ?? [])],
|
|
104
|
+
cwd: this.context.profile?.workdir ?? this.context.workdir,
|
|
105
|
+
env: { ...mcpTokenEnvironment(servers), SKILLSHARE_SESSION_ID: this.context.sessionId },
|
|
106
|
+
spawnAgent: this.options.spawnAgent,
|
|
107
|
+
onLine: line => this.receive(line),
|
|
108
|
+
});
|
|
109
|
+
void this.process.exited.then(code => this.onExit(code));
|
|
110
|
+
await this.request('initialize', {
|
|
111
|
+
clientInfo: { name: 'skillshare', title: 'Skillshare', version: this.options.clientVersion ?? '0.0.0' },
|
|
112
|
+
// Unified diffs are not shown in the transcript, so Codex need not send them.
|
|
113
|
+
capabilities: { experimentalApi: true, optOutNotificationMethods: ['turn/diff/updated', 'mcpServer/startupStatus/updated'] },
|
|
114
|
+
});
|
|
115
|
+
this.notify('initialized');
|
|
116
|
+
const params = codexThreadParams(this.context);
|
|
117
|
+
const result = start.resumeThreadId
|
|
118
|
+
? await this.request('thread/resume', { threadId: start.resumeThreadId, ...params })
|
|
119
|
+
: await this.request('thread/start', params);
|
|
120
|
+
const threadId = result?.thread?.id;
|
|
121
|
+
if (typeof threadId !== 'string')
|
|
122
|
+
throw new Error('Codex did not return a thread.');
|
|
123
|
+
this.threadId = threadId;
|
|
124
|
+
this.context.thread(threadId);
|
|
125
|
+
}
|
|
126
|
+
turn(message) {
|
|
127
|
+
const { configuration } = this.context.start;
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
this.current = { resolve, usage: { input: 0, output: 0 } };
|
|
130
|
+
this.request('turn/start', {
|
|
131
|
+
threadId: this.threadId, input: [{ type: 'text', text: message.text }],
|
|
132
|
+
...(configuration.reasoning !== DEFAULT ? { effort: configuration.reasoning } : {}),
|
|
133
|
+
}).then(result => {
|
|
134
|
+
if (this.current && typeof result?.turn?.id === 'string')
|
|
135
|
+
this.current.id ??= result.turn.id;
|
|
136
|
+
}, error => {
|
|
137
|
+
this.current = null;
|
|
138
|
+
reject(error);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
async interrupt() {
|
|
143
|
+
if (this.current?.id && this.threadId)
|
|
144
|
+
await this.request('turn/interrupt', { threadId: this.threadId, turnId: this.current.id });
|
|
145
|
+
}
|
|
146
|
+
async close() {
|
|
147
|
+
this.closing = true;
|
|
148
|
+
await this.process?.kill();
|
|
149
|
+
}
|
|
150
|
+
request(method, params) {
|
|
151
|
+
if (!this.process)
|
|
152
|
+
return Promise.reject(new Error('Codex is not running.'));
|
|
153
|
+
const id = ++this.nextId;
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
this.pending.set(id, { resolve, reject });
|
|
156
|
+
if (!this.process.write(JSON.stringify({ id, method, params }))) {
|
|
157
|
+
this.pending.delete(id);
|
|
158
|
+
reject(new Error('Codex is not accepting input.'));
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
notify(method, params) {
|
|
163
|
+
this.process?.write(JSON.stringify(params === undefined ? { method } : { method, params }));
|
|
164
|
+
}
|
|
165
|
+
receive(line) {
|
|
166
|
+
let message;
|
|
167
|
+
try {
|
|
168
|
+
message = JSON.parse(line);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (message.id !== undefined && typeof message.method === 'string')
|
|
174
|
+
return this.answer(message);
|
|
175
|
+
if (message.id !== undefined) {
|
|
176
|
+
const pending = this.pending.get(message.id);
|
|
177
|
+
if (!pending)
|
|
178
|
+
return;
|
|
179
|
+
this.pending.delete(message.id);
|
|
180
|
+
if (message.error)
|
|
181
|
+
pending.reject(new Error(codexErrorText(message.error.message) ?? 'Codex rejected the request.'));
|
|
182
|
+
else
|
|
183
|
+
pending.resolve(message.result);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (typeof message.method === 'string')
|
|
187
|
+
this.notification(message.method, message.params ?? {});
|
|
188
|
+
}
|
|
189
|
+
/** Codex asks the client only for approvals and input, which a session never gives: the approval policy is never. */
|
|
190
|
+
answer(message) {
|
|
191
|
+
const reply = /requestApproval$/.test(message.method) ? { id: message.id, result: { decision: 'decline' } }
|
|
192
|
+
: { id: message.id, error: { code: -32601, message: 'Skillshare sessions do not answer this request.' } };
|
|
193
|
+
this.process?.write(JSON.stringify(reply));
|
|
194
|
+
}
|
|
195
|
+
notification(method, params) {
|
|
196
|
+
// Subagent threads report on the same connection; only the session's own thread is transcribed.
|
|
197
|
+
if (typeof params.threadId === 'string' && this.threadId && params.threadId !== this.threadId)
|
|
198
|
+
return;
|
|
199
|
+
switch (method) {
|
|
200
|
+
case 'item/started':
|
|
201
|
+
case 'item/completed': {
|
|
202
|
+
const item = codexItem(params.item);
|
|
203
|
+
if (!item)
|
|
204
|
+
return;
|
|
205
|
+
if (item.kind === 'assistant' || item.kind === 'reasoning') {
|
|
206
|
+
if (method === 'item/completed' || item.text)
|
|
207
|
+
this.texts.set(item.key, item.text ?? '');
|
|
208
|
+
else
|
|
209
|
+
item.text = this.texts.get(item.key) ?? '';
|
|
210
|
+
item.status = method === 'item/completed' ? 'completed' : 'inProgress';
|
|
211
|
+
}
|
|
212
|
+
this.context.item(item);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
case 'item/agentMessage/delta':
|
|
216
|
+
case 'item/reasoning/summaryTextDelta': {
|
|
217
|
+
const key = String(params.itemId ?? '');
|
|
218
|
+
if (!key)
|
|
219
|
+
return;
|
|
220
|
+
const text = (this.texts.get(key) ?? '') + String(params.delta ?? '');
|
|
221
|
+
this.texts.set(key, text);
|
|
222
|
+
this.context.item({ key, kind: method === 'item/agentMessage/delta' ? 'assistant' : 'reasoning', status: 'inProgress', text });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
case 'turn/plan/updated': {
|
|
226
|
+
const steps = Array.isArray(params.plan) ? params.plan : [];
|
|
227
|
+
const text = steps.map((step) => `- [${step.status === 'completed' ? 'x' : ' '}] ${step.step ?? ''}`).join('\n');
|
|
228
|
+
if (text && params.turnId)
|
|
229
|
+
this.context.item({ key: `plan-${params.turnId}`, kind: 'plan', text: clip(text) });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
case 'thread/tokenUsage/updated': {
|
|
233
|
+
// total covers the whole thread, including turns from before a resume; last is one model request, so a turn adds them up.
|
|
234
|
+
const last = params.tokenUsage?.last;
|
|
235
|
+
if (last && this.current) {
|
|
236
|
+
this.current.usage.input += Number(last.inputTokens) || 0;
|
|
237
|
+
this.current.usage.output += Number(last.outputTokens) || 0;
|
|
238
|
+
}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
case 'error': {
|
|
242
|
+
const text = codexErrorText(params.error?.message);
|
|
243
|
+
if (this.current && text)
|
|
244
|
+
this.current.error = text;
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
case 'turn/started':
|
|
248
|
+
if (this.current && typeof params.turn?.id === 'string')
|
|
249
|
+
this.current.id ??= params.turn.id;
|
|
250
|
+
return;
|
|
251
|
+
case 'turn/completed': {
|
|
252
|
+
const turn = params.turn ?? {};
|
|
253
|
+
const current = this.current;
|
|
254
|
+
if (!current || current.id && turn.id && turn.id !== current.id)
|
|
255
|
+
return;
|
|
256
|
+
this.current = null;
|
|
257
|
+
const status = turn.status === 'completed' ? 'completed' : turn.status === 'interrupted' ? 'interrupted' : 'failed';
|
|
258
|
+
const stats = { inputTokens: current.usage.input, outputTokens: current.usage.output };
|
|
259
|
+
current.resolve(status === 'failed'
|
|
260
|
+
? { status, error: codexErrorText(turn.error?.message) ?? current.error ?? 'The turn failed.', stats }
|
|
261
|
+
: { status, stats });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
onExit(code) {
|
|
267
|
+
const reason = `Codex exited${code === null ? '' : ` with code ${code}`}${this.process?.stderrTail() ? `: ${this.process.stderrTail()}` : '.'}`;
|
|
268
|
+
for (const pending of this.pending.values())
|
|
269
|
+
pending.reject(new Error(reason));
|
|
270
|
+
this.pending.clear();
|
|
271
|
+
this.current?.resolve({ status: 'failed', error: reason });
|
|
272
|
+
this.current = null;
|
|
273
|
+
if (!this.closing)
|
|
274
|
+
this.context.exited(reason);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -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
|
+
}
|
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.';
|
|
@@ -71,6 +73,7 @@ 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.');
|
|
74
77
|
try {
|
|
75
78
|
await listen({ client, listenerId, name: flags.name, workdir, maxConcurrency: max, sandbox, signal: abort.signal, onProgress: console.log });
|
|
76
79
|
}
|
|
@@ -100,11 +103,13 @@ export function renderDetected(listenerId, server, detected) {
|
|
|
100
103
|
}
|
|
101
104
|
export async function listen(options) {
|
|
102
105
|
const detect = options.detect ?? (() => detectAgents());
|
|
103
|
-
const
|
|
106
|
+
const codex = createCodexDriver({ clientVersion: readCliVersion() });
|
|
107
|
+
const claude = createClaudeDriver();
|
|
108
|
+
const driver = options.driver ?? (start => start.configuration.agent === 'codex' ? codex : claude);
|
|
104
109
|
const wait = options.wait ?? (async (ms, signal) => { await delay(ms, undefined, { signal }); });
|
|
105
110
|
const now = options.now ?? Date.now;
|
|
106
111
|
const log = options.onProgress ?? (() => { });
|
|
107
|
-
const
|
|
112
|
+
const sessions = new Map();
|
|
108
113
|
let detected = await detect();
|
|
109
114
|
let registeredFingerprint = '';
|
|
110
115
|
let detectedAt = now();
|
|
@@ -119,48 +124,52 @@ export async function listen(options) {
|
|
|
119
124
|
registeredFingerprint = fingerprint;
|
|
120
125
|
for (const problem of detected.problems)
|
|
121
126
|
log(`! ${problem}`);
|
|
122
|
-
log(`Listening as "${registered.name}" with ${registered.effectiveConcurrency} slot(s): ${describe(detected)}.
|
|
127
|
+
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
128
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
: { leaseToken: assignment.leaseToken, reason: result.reason, cancelled: result.cancelled, stats: result.stats };
|
|
128
|
-
for (let attempt = 0; attempt < 5; attempt++) {
|
|
129
|
+
/** Posts a batch of session events: a rejected lease means the server moved on and the agent must stop. */
|
|
130
|
+
function uploader(sessionId, leaseToken) {
|
|
131
|
+
return async (events) => {
|
|
129
132
|
try {
|
|
130
|
-
await send(options.client, 'POST',
|
|
131
|
-
return;
|
|
133
|
+
await send(options.client, 'POST', `${root}/sessions/${sessionId}/events`, { leaseToken, events });
|
|
134
|
+
return 'ok';
|
|
132
135
|
}
|
|
133
136
|
catch (error) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
if (attempt < 4)
|
|
140
|
-
await delay(2000 * (attempt + 1)).catch(() => { });
|
|
137
|
+
if (error instanceof AgentsHttpError && error.status >= 400 && error.status < 500)
|
|
138
|
+
return 'gone';
|
|
139
|
+
return 'retry';
|
|
141
140
|
}
|
|
142
|
-
}
|
|
143
|
-
log(`Task ${assignment.taskId} result could not be delivered; the server will retry the task after its lease expires.`);
|
|
141
|
+
};
|
|
144
142
|
}
|
|
145
|
-
function start(
|
|
146
|
-
if (
|
|
143
|
+
function start(command) {
|
|
144
|
+
if (!command.start || !command.leaseToken)
|
|
147
145
|
return;
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
146
|
+
// A start for a session already held replaces its agent, for example to hand it fresh credentials.
|
|
147
|
+
const previous = sessions.get(command.sessionId);
|
|
148
|
+
if (previous)
|
|
149
|
+
void previous.stop();
|
|
150
|
+
const profile = command.start.configuration.profile ? detected.profiles.find(p => p.name === command.start.configuration.profile) : undefined;
|
|
151
|
+
const runner = new SessionRunner({
|
|
152
|
+
sessionId: command.sessionId, leaseToken: command.leaseToken, start: command.start, workdir: options.workdir, profile, sandbox: options.sandbox === true,
|
|
153
|
+
driver: driver(command.start), upload: uploader(command.sessionId, command.leaseToken), idleMs: options.idleMs, flushIntervalMs: options.flushIntervalMs,
|
|
154
|
+
now,
|
|
155
|
+
});
|
|
156
|
+
sessions.set(command.sessionId, runner);
|
|
157
|
+
log(`${command.start.resumeThreadId ? 'Resumed' : 'Started'} session ${command.sessionId} "${command.start.title}" with ${label(command.start)}.`);
|
|
158
|
+
void runner.done.then(() => {
|
|
159
|
+
if (sessions.get(command.sessionId) === runner)
|
|
160
|
+
sessions.delete(command.sessionId);
|
|
161
|
+
log(`Closed session ${command.sessionId}.`);
|
|
162
|
+
});
|
|
163
|
+
runner.send(command.messages ?? []);
|
|
164
|
+
}
|
|
165
|
+
function handle(command) {
|
|
166
|
+
const runner = sessions.get(command.sessionId);
|
|
167
|
+
switch (command.type) {
|
|
168
|
+
case 'start': return start(command);
|
|
169
|
+
case 'send': return runner?.send(command.messages ?? []);
|
|
170
|
+
case 'interrupt': return void runner?.interrupt();
|
|
171
|
+
case 'close': return void runner?.close();
|
|
172
|
+
}
|
|
164
173
|
}
|
|
165
174
|
try {
|
|
166
175
|
while (!options.signal.aborted) {
|
|
@@ -173,17 +182,14 @@ export async function listen(options) {
|
|
|
173
182
|
await register();
|
|
174
183
|
const polledAt = now();
|
|
175
184
|
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 })),
|
|
185
|
+
// A session that is already closing is not reported, so the server treats its agent as gone.
|
|
186
|
+
sessions: [...sessions.values()].filter(r => !r.stopping).map(r => ({ sessionId: r.sessionId, leaseToken: r.leaseToken })),
|
|
179
187
|
waitSeconds: options.pollWaitSeconds ?? 25,
|
|
180
188
|
}, options.signal);
|
|
181
189
|
failures = 0;
|
|
182
|
-
for (const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
start(assignment);
|
|
186
|
-
if (response.assignments.length === 0 && now() - polledAt < 1000)
|
|
190
|
+
for (const command of response.commands)
|
|
191
|
+
handle(command);
|
|
192
|
+
if (response.commands.length === 0 && now() - polledAt < 1000)
|
|
187
193
|
await wait(1000, options.signal).catch(() => { });
|
|
188
194
|
}
|
|
189
195
|
catch (error) {
|
|
@@ -206,13 +212,12 @@ export async function listen(options) {
|
|
|
206
212
|
}
|
|
207
213
|
}
|
|
208
214
|
finally {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
await Promise.all([...running.values()].map(task => task.done));
|
|
215
|
+
// Closing reports each session as closed, so the server knows at once that the next message must resume it.
|
|
216
|
+
await Promise.all([...sessions.values()].map(runner => runner.close()));
|
|
212
217
|
}
|
|
213
218
|
}
|
|
214
|
-
function label(
|
|
215
|
-
const { configuration } =
|
|
219
|
+
function label(start) {
|
|
220
|
+
const { configuration } = start;
|
|
216
221
|
if (configuration.profile)
|
|
217
222
|
return `profile ${configuration.profile}`;
|
|
218
223
|
return [configuration.agent === 'claude' ? 'Claude Code' : 'Codex', configuration.model, configuration.reasoning]
|
package/dist/help.js
CHANGED
|
@@ -115,7 +115,7 @@ export const COMMANDS = [
|
|
|
115
115
|
'agents listen [local] [--name <name>] [--workdir <directory>] [--max-concurrency <1-4>] [--sandbox]',
|
|
116
116
|
'agents status [local] [--json]',
|
|
117
117
|
],
|
|
118
|
-
description: 'A listener reports the installed agents, their models and reasoning levels, and the local profiles from ~/.config/skillshare/agents.json.
|
|
118
|
+
description: 'A listener reports the installed agents, their models and reasoning levels, and the local profiles from ~/.config/skillshare/agents.json. It runs Claude Code and Codex sessions that you start and continue under Sessions in Skillshare; under Connected agents you choose which listener and configuration runs Teams ๐ sessions. Agents use your own login and run with your full local permissions, without permission prompts, unless --sandbox (or "sandbox": true in a profile) keeps them read-only. An idle agent is stopped after 15 minutes and resumed by the next message.',
|
|
119
119
|
examples: ['skillshare agents status', 'skillshare agents listen --workdir ~/Research'],
|
|
120
120
|
flags: ['name', 'workdir', 'max-concurrency', 'sandbox', 'json'],
|
|
121
121
|
},
|
package/package.json
CHANGED
package/dist/agents-run.js
DELETED
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
const DEFAULT = 'default';
|
|
6
|
-
/** Read-only tools Claude Code may use in a sandboxed run; MCP servers from the assignment are added per server. */
|
|
7
|
-
export const CLAUDE_SANDBOX_TOOLS = ['Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch'];
|
|
8
|
-
export const SANDBOX_NOTE = '\n\nThis run is sandboxed: you can read files, search and use the provided MCP tools, but you cannot change files or run ' +
|
|
9
|
-
'commands that modify anything. Give your final answer as your last message; writing SKILLSHARE_AGENT_RESULT may not be possible.';
|
|
10
|
-
/**
|
|
11
|
-
* Builds the agent invocation. The prompt goes to stdin and tokens to environment variables, so neither
|
|
12
|
-
* appears in the process list. Agents run without permission prompts, with the user's own configuration.
|
|
13
|
-
*/
|
|
14
|
-
export function isSandboxed(assignment, options) {
|
|
15
|
-
return options.sandbox === true || options.profile?.sandbox === true || assignment.configuration.sandbox === true;
|
|
16
|
-
}
|
|
17
|
-
export function buildAgentCommand(assignment, options) {
|
|
18
|
-
const sandbox = isSandboxed(assignment, options);
|
|
19
|
-
const { configuration } = assignment;
|
|
20
|
-
const env = { SKILLSHARE_AGENT_RESULT: join(options.directory, 'result.md'), SKILLSHARE_TASK_ID: assignment.taskId };
|
|
21
|
-
const servers = assignment.mcpServers.map((server, index) => {
|
|
22
|
-
const variable = `SKILLSHARE_MCP_TOKEN_${index}`;
|
|
23
|
-
env[variable] = server.bearerToken;
|
|
24
|
-
return { name: server.name.replace(/[^A-Za-z0-9_-]/g, '_') || `server_${index}`, url: server.url, variable };
|
|
25
|
-
});
|
|
26
|
-
const cwd = options.profile?.workdir ?? options.workdir;
|
|
27
|
-
const extra = options.profile?.args ?? [];
|
|
28
|
-
if (configuration.agent === 'claude') {
|
|
29
|
-
// A sandboxed run never prompts: anything outside the read-only allowlist is denied.
|
|
30
|
-
const args = ['-p', '--output-format', 'json', ...(sandbox
|
|
31
|
-
? ['--permission-mode', 'dontAsk', '--allowedTools', ...CLAUDE_SANDBOX_TOOLS, ...servers.map(server => `mcp__${server.name}`)]
|
|
32
|
-
: ['--dangerously-skip-permissions'])];
|
|
33
|
-
if (configuration.model !== DEFAULT)
|
|
34
|
-
args.push('--model', configuration.model);
|
|
35
|
-
if (configuration.reasoning !== DEFAULT)
|
|
36
|
-
args.push('--effort', configuration.reasoning);
|
|
37
|
-
if (servers.length > 0)
|
|
38
|
-
args.push('--mcp-config', join(options.directory, 'mcp.json'));
|
|
39
|
-
return { command: 'claude', args: [...args, ...extra], env, cwd };
|
|
40
|
-
}
|
|
41
|
-
const args = ['exec', '--skip-git-repo-check', ...(sandbox ? ['--sandbox', 'read-only', '-c', 'approval_policy="never"'] : ['--dangerously-bypass-approvals-and-sandbox']),
|
|
42
|
-
'--output-last-message', join(options.directory, 'last-message.md')];
|
|
43
|
-
if (configuration.model !== DEFAULT)
|
|
44
|
-
args.push('-m', configuration.model);
|
|
45
|
-
if (configuration.reasoning !== DEFAULT)
|
|
46
|
-
args.push('-c', `model_reasoning_effort=${JSON.stringify(configuration.reasoning)}`);
|
|
47
|
-
for (const server of servers)
|
|
48
|
-
args.push('-c', `mcp_servers.${server.name}.url=${JSON.stringify(server.url)}`, '-c', `mcp_servers.${server.name}.bearer_token_env_var=${JSON.stringify(server.variable)}`);
|
|
49
|
-
return { command: 'codex', args: [...args, ...extra, '-'], env, cwd };
|
|
50
|
-
}
|
|
51
|
-
export function claudeMcpConfig(servers) {
|
|
52
|
-
return JSON.stringify({
|
|
53
|
-
mcpServers: Object.fromEntries(servers.map((server, index) => [server.name.replace(/[^A-Za-z0-9_-]/g, '_') || `server_${index}`,
|
|
54
|
-
{ type: 'http', url: server.url, headers: { Authorization: `Bearer \${SKILLSHARE_MCP_TOKEN_${index}}` } }])),
|
|
55
|
-
}, null, 2);
|
|
56
|
-
}
|
|
57
|
-
/** Reads Claude Code's --output-format json summary. */
|
|
58
|
-
export function parseClaudeOutput(stdout) {
|
|
59
|
-
try {
|
|
60
|
-
const data = JSON.parse(stdout.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? '');
|
|
61
|
-
const usage = data.usage ?? {};
|
|
62
|
-
const input = [usage.input_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens]
|
|
63
|
-
.filter((value) => typeof value === 'number');
|
|
64
|
-
return {
|
|
65
|
-
result: typeof data.result === 'string' ? data.result : undefined,
|
|
66
|
-
isError: data.is_error === true,
|
|
67
|
-
stats: {
|
|
68
|
-
durationMs: typeof data.duration_ms === 'number' ? data.duration_ms : undefined,
|
|
69
|
-
turns: typeof data.num_turns === 'number' ? data.num_turns : undefined,
|
|
70
|
-
costUsd: typeof data.total_cost_usd === 'number' ? data.total_cost_usd : undefined,
|
|
71
|
-
inputTokens: input.length > 0 ? input.reduce((a, b) => a + b, 0) : undefined,
|
|
72
|
-
outputTokens: typeof usage.output_tokens === 'number' ? usage.output_tokens : undefined,
|
|
73
|
-
},
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
catch {
|
|
77
|
-
return { isError: false, stats: {} };
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
export async function runAgent(assignment, options) {
|
|
81
|
-
const now = options.now ?? Date.now;
|
|
82
|
-
const started = now();
|
|
83
|
-
const directory = await mkdtemp(join(tmpdir(), 'skillshare-agent-'));
|
|
84
|
-
try {
|
|
85
|
-
const command = buildAgentCommand(assignment, { directory, workdir: options.workdir, profile: options.profile, sandbox: options.sandbox });
|
|
86
|
-
if (assignment.configuration.agent === 'claude' && assignment.mcpServers.length > 0)
|
|
87
|
-
await writeFile(join(directory, 'mcp.json'), claudeMcpConfig(assignment.mcpServers), { mode: 0o600 });
|
|
88
|
-
const prompt = assignment.prompt + (isSandboxed(assignment, options) ? SANDBOX_NOTE : '');
|
|
89
|
-
const outcome = await execute(command, prompt, Math.max(1, assignment.timeoutSeconds) * 1000, options.signal, options.spawnAgent ?? spawn);
|
|
90
|
-
const measured = { durationMs: now() - started };
|
|
91
|
-
const claude = assignment.configuration.agent === 'claude' ? parseClaudeOutput(outcome.stdout) : undefined;
|
|
92
|
-
const stats = { ...measured, ...Object.fromEntries(Object.entries(claude?.stats ?? {}).filter(([, value]) => value !== undefined)) };
|
|
93
|
-
if (outcome.cancelled)
|
|
94
|
-
return { ok: false, reason: 'Cancelled on request.', cancelled: true, stats };
|
|
95
|
-
if (outcome.timedOut)
|
|
96
|
-
return { ok: false, reason: `The agent did not finish within ${assignment.timeoutSeconds} seconds.`, cancelled: false, stats };
|
|
97
|
-
if (outcome.spawnError)
|
|
98
|
-
return { ok: false, reason: `Could not start ${command.command}: ${outcome.spawnError}`, cancelled: false, stats };
|
|
99
|
-
const answer = await readAnswer(directory, assignment.configuration.agent, claude?.result);
|
|
100
|
-
if (outcome.code !== 0 || claude?.isError) {
|
|
101
|
-
const detail = (claude?.isError ? claude.result : undefined) ?? lastLines(outcome.stderr) ?? `exit code ${outcome.code}`;
|
|
102
|
-
return { ok: false, reason: `${command.command} failed: ${detail}`.slice(0, 500), cancelled: false, stats };
|
|
103
|
-
}
|
|
104
|
-
if (!answer)
|
|
105
|
-
return { ok: false, reason: 'The agent finished without an answer.', cancelled: false, stats };
|
|
106
|
-
return { ok: true, markdown: answer, stats };
|
|
107
|
-
}
|
|
108
|
-
finally {
|
|
109
|
-
await rm(directory, { recursive: true, force: true });
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
async function readAnswer(directory, agent, claudeResult) {
|
|
113
|
-
for (const file of ['result.md', ...(agent === 'codex' ? ['last-message.md'] : [])]) {
|
|
114
|
-
try {
|
|
115
|
-
const text = (await readFile(join(directory, file), 'utf8')).trim();
|
|
116
|
-
if (text)
|
|
117
|
-
return text;
|
|
118
|
-
}
|
|
119
|
-
catch { }
|
|
120
|
-
}
|
|
121
|
-
return claudeResult?.trim() || undefined;
|
|
122
|
-
}
|
|
123
|
-
function lastLines(text) {
|
|
124
|
-
const lines = text.trim().split(/\r?\n/).filter(line => line.trim()).slice(-3).join(' ').trim();
|
|
125
|
-
return lines ? lines.slice(-300) : undefined;
|
|
126
|
-
}
|
|
127
|
-
function execute(command, input, timeoutMs, signal, spawnAgent) {
|
|
128
|
-
return new Promise(resolvePromise => {
|
|
129
|
-
const posix = process.platform !== 'win32';
|
|
130
|
-
const child = spawnAgent(command.command, command.args, {
|
|
131
|
-
cwd: command.cwd, env: { ...process.env, ...command.env }, stdio: ['pipe', 'pipe', 'pipe'],
|
|
132
|
-
// A separate process group lets cancellation stop the agent's own child processes too.
|
|
133
|
-
detached: posix, windowsHide: true, shell: !posix,
|
|
134
|
-
});
|
|
135
|
-
let stdout = '';
|
|
136
|
-
let stderr = '';
|
|
137
|
-
let cancelled = false;
|
|
138
|
-
let timedOut = false;
|
|
139
|
-
let settled = false;
|
|
140
|
-
const stop = () => {
|
|
141
|
-
const kill = (sig) => {
|
|
142
|
-
try {
|
|
143
|
-
if (posix && child.pid)
|
|
144
|
-
process.kill(-child.pid, sig);
|
|
145
|
-
else
|
|
146
|
-
child.kill(sig);
|
|
147
|
-
}
|
|
148
|
-
catch { }
|
|
149
|
-
};
|
|
150
|
-
kill('SIGTERM');
|
|
151
|
-
setTimeout(() => kill('SIGKILL'), 5000).unref();
|
|
152
|
-
};
|
|
153
|
-
const onAbort = () => { cancelled = true; stop(); };
|
|
154
|
-
const timer = setTimeout(() => { timedOut = true; stop(); }, timeoutMs);
|
|
155
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
156
|
-
const finish = (outcome) => {
|
|
157
|
-
if (settled)
|
|
158
|
-
return;
|
|
159
|
-
settled = true;
|
|
160
|
-
clearTimeout(timer);
|
|
161
|
-
signal.removeEventListener('abort', onAbort);
|
|
162
|
-
resolvePromise(outcome);
|
|
163
|
-
};
|
|
164
|
-
child.stdout?.on('data', chunk => { if (stdout.length < 2_000_000)
|
|
165
|
-
stdout += chunk; });
|
|
166
|
-
child.stderr?.on('data', chunk => { stderr = (stderr + chunk).slice(-4000); });
|
|
167
|
-
child.on('error', error => finish({ code: null, stdout, stderr, cancelled, timedOut, spawnError: error.message }));
|
|
168
|
-
child.on('close', code => finish({ code, stdout, stderr, cancelled, timedOut }));
|
|
169
|
-
child.stdin?.on('error', () => { });
|
|
170
|
-
child.stdin?.end(input);
|
|
171
|
-
if (signal.aborted)
|
|
172
|
-
onAbort();
|
|
173
|
-
});
|
|
174
|
-
}
|