@quadient/skillshare 1.9.0 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -7
- package/dist/agents-claude.js +322 -0
- package/dist/agents-codex.js +292 -0
- package/dist/agents-detect.js +73 -12
- package/dist/agents-process.js +50 -0
- package/dist/agents-session.js +296 -0
- package/dist/agents.js +64 -52
- package/dist/help.js +5 -5
- package/dist/index.js +3 -2
- package/dist/teams.js +39 -14
- package/package.json +1 -1
- package/dist/agents-run.js +0 -174
package/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
|
|
@@ -53,7 +59,8 @@ segment are sent to Skillshare.
|
|
|
53
59
|
Starting with 1.7.0, `skillshare teams login` opens a dedicated local browser profile,
|
|
54
60
|
connects your own Teams account to Skillshare and verifies the connection. Sign in with
|
|
55
61
|
the same Microsoft account as Skillshare. The server refreshes captured tokens in the
|
|
56
|
-
background. `teams watch` renews an expired Microsoft grant through local browser SSO
|
|
62
|
+
background. `teams watch` renews an expired Microsoft grant through local browser SSO; `agents listen` does the same
|
|
63
|
+
while it runs, so a computer that runs agents needs no separate `teams watch` (`--no-teams-watch` turns it off).
|
|
57
64
|
Keep that process running for automatic recovery beyond the SPA refresh token lifetime;
|
|
58
65
|
another interactive CLI login is needed only when SSO cannot complete, for example MFA.
|
|
59
66
|
|
|
@@ -85,7 +92,7 @@ permissions, Microsoft session lifetime and file-tool differences in the hosted
|
|
|
85
92
|
|
|
86
93
|
Starting with 1.9.0:
|
|
87
94
|
|
|
88
|
-
- `agents listen` and `agents status` run Claude Code and Codex
|
|
95
|
+
- `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
96
|
- 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
97
|
- `show` accepts a skill name as well as an id.
|
|
91
98
|
- `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,322 @@
|
|
|
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
|
+
// Replayed user messages tell when Claude Code took a message, which is how a steered message is known to have joined the turn.
|
|
17
|
+
const args = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--include-partial-messages', '--replay-user-messages',
|
|
18
|
+
...(sandbox
|
|
19
|
+
// A sandboxed session never prompts: anything outside the read-only allowlist is denied.
|
|
20
|
+
? ['--permission-mode', 'dontAsk', '--allowedTools', ...CLAUDE_SANDBOX_TOOLS, ...servers.map((server, index) => `mcp__${mcpServerName(server, index)}`)]
|
|
21
|
+
: ['--dangerously-skip-permissions'])];
|
|
22
|
+
if (start.configuration.model !== DEFAULT)
|
|
23
|
+
args.push('--model', start.configuration.model);
|
|
24
|
+
if (start.configuration.reasoning !== DEFAULT)
|
|
25
|
+
args.push('--effort', start.configuration.reasoning);
|
|
26
|
+
if (mcpConfigPath)
|
|
27
|
+
args.push('--mcp-config', mcpConfigPath);
|
|
28
|
+
const instructions = [start.instructions?.trim(), sandbox ? SANDBOX_NOTE : undefined].filter(Boolean).join('\n\n');
|
|
29
|
+
if (instructions)
|
|
30
|
+
args.push('--append-system-prompt', instructions);
|
|
31
|
+
if (start.resumeThreadId)
|
|
32
|
+
args.push('--resume', start.resumeThreadId);
|
|
33
|
+
return [...args, ...(context.profile?.args ?? [])];
|
|
34
|
+
}
|
|
35
|
+
export function claudeMcpConfig(servers) {
|
|
36
|
+
return JSON.stringify({
|
|
37
|
+
mcpServers: Object.fromEntries(servers.map((server, index) => [mcpServerName(server, index),
|
|
38
|
+
{ type: 'http', url: server.url, headers: { Authorization: `Bearer \${SKILLSHARE_MCP_TOKEN_${index}}` } }])),
|
|
39
|
+
}, null, 2);
|
|
40
|
+
}
|
|
41
|
+
/** A tool call as a transcript entry: shell commands and file edits get their own kinds, everything else is a tool. */
|
|
42
|
+
export function claudeToolItem(block) {
|
|
43
|
+
const input = block.input ?? {};
|
|
44
|
+
const name = String(block.name ?? 'tool');
|
|
45
|
+
const key = String(block.id);
|
|
46
|
+
const text = (value) => typeof value === 'string' ? value : '';
|
|
47
|
+
switch (name) {
|
|
48
|
+
case 'Bash': return { key, kind: 'command', status: 'inProgress', title: oneLine(text(input.command)), detail: text(input.description) || undefined };
|
|
49
|
+
case 'Edit':
|
|
50
|
+
case 'MultiEdit':
|
|
51
|
+
case 'Write':
|
|
52
|
+
return { key, kind: 'fileChange', status: 'inProgress', title: text(input.file_path) };
|
|
53
|
+
case 'NotebookEdit': return { key, kind: 'fileChange', status: 'inProgress', title: text(input.notebook_path) };
|
|
54
|
+
case 'TodoWrite': {
|
|
55
|
+
const todos = Array.isArray(input.todos) ? input.todos : [];
|
|
56
|
+
return { key, kind: 'plan', status: 'completed',
|
|
57
|
+
text: clip(todos.map((todo) => `- [${todo.status === 'completed' ? 'x' : ' '}] ${todo.content ?? ''}`).join('\n')) };
|
|
58
|
+
}
|
|
59
|
+
case 'Task':
|
|
60
|
+
case 'Agent': return { key, kind: 'tool', status: 'inProgress', title: oneLine(`Subagent: ${text(input.description) || text(input.subagent_type)}`) };
|
|
61
|
+
}
|
|
62
|
+
const mcp = /^mcp__(.+?)__(.+)$/.exec(name);
|
|
63
|
+
if (mcp)
|
|
64
|
+
return { key, kind: 'tool', status: 'inProgress', title: `${mcp[1]} · ${mcp[2]}` };
|
|
65
|
+
const subject = text(input.file_path) || text(input.pattern) || text(input.url) || text(input.query) || text(input.path);
|
|
66
|
+
return { key, kind: 'tool', status: 'inProgress', title: oneLine(subject ? `${name} ${subject}` : name) };
|
|
67
|
+
}
|
|
68
|
+
export function createClaudeDriver(options = {}) {
|
|
69
|
+
return context => new ClaudeDriver(context, options);
|
|
70
|
+
}
|
|
71
|
+
class ClaudeDriver {
|
|
72
|
+
context;
|
|
73
|
+
options;
|
|
74
|
+
process = null;
|
|
75
|
+
directory = null;
|
|
76
|
+
sessionId = null;
|
|
77
|
+
current = null;
|
|
78
|
+
controls = new Map();
|
|
79
|
+
/** Text and tool entries by key, so later frames can update an entry the transcript already shows. */
|
|
80
|
+
items = new Map();
|
|
81
|
+
blocks = new Map();
|
|
82
|
+
streamed = new Map();
|
|
83
|
+
messageId = '';
|
|
84
|
+
/** Messages written to Claude Code whose replay has not come back yet, in order. */
|
|
85
|
+
echoes = [];
|
|
86
|
+
/** Steered messages Claude Code will run as their own turn; turn() then only waits for their outcome. */
|
|
87
|
+
submitted = new Set();
|
|
88
|
+
/** Outcomes of such turns that ended before turn() asked for them. */
|
|
89
|
+
early = [];
|
|
90
|
+
costSoFar = 0;
|
|
91
|
+
closing = false;
|
|
92
|
+
constructor(context, options) {
|
|
93
|
+
this.context = context;
|
|
94
|
+
this.options = options;
|
|
95
|
+
}
|
|
96
|
+
async open() {
|
|
97
|
+
const servers = this.context.start.mcpServers ?? [];
|
|
98
|
+
let mcpConfig;
|
|
99
|
+
if (servers.length > 0) {
|
|
100
|
+
this.directory = await mkdtemp(join(tmpdir(), 'skillshare-session-'));
|
|
101
|
+
mcpConfig = join(this.directory, 'mcp.json');
|
|
102
|
+
await writeFile(mcpConfig, claudeMcpConfig(servers), { mode: 0o600 });
|
|
103
|
+
}
|
|
104
|
+
this.process = startAgentProcess({
|
|
105
|
+
command: this.options.command ?? 'claude',
|
|
106
|
+
args: claudeArgs(this.context, mcpConfig),
|
|
107
|
+
cwd: this.context.profile?.workdir ?? this.context.workdir,
|
|
108
|
+
env: {
|
|
109
|
+
...mcpTokenEnvironment(servers), SKILLSHARE_SESSION_ID: this.context.sessionId,
|
|
110
|
+
// Claude Code lets auto-memory write to its own folder even under the read-only allowlist; a sandboxed session writes nothing.
|
|
111
|
+
...(isSandboxed(this.context.start.configuration, this.context) ? { CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1' } : {}),
|
|
112
|
+
},
|
|
113
|
+
spawnAgent: this.options.spawnAgent,
|
|
114
|
+
onLine: line => this.receive(line),
|
|
115
|
+
});
|
|
116
|
+
void this.process.exited.then(code => this.onExit(code));
|
|
117
|
+
// Claude Code answers initialize once it is ready, which turns a bad install or a bad flag into a clear start failure.
|
|
118
|
+
await this.control({ subtype: 'initialize' });
|
|
119
|
+
if (this.context.start.resumeThreadId)
|
|
120
|
+
this.adopt(this.context.start.resumeThreadId);
|
|
121
|
+
}
|
|
122
|
+
turn(message) {
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
if (this.submitted.delete(message.id)) {
|
|
125
|
+
const early = this.early.shift();
|
|
126
|
+
if (early)
|
|
127
|
+
resolve(early);
|
|
128
|
+
else
|
|
129
|
+
this.current = { resolve, interrupted: false };
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
this.current = { resolve, interrupted: false };
|
|
133
|
+
if (!this.write(message.text)) {
|
|
134
|
+
this.current = null;
|
|
135
|
+
reject(new Error('Claude Code is not accepting input.'));
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Claude Code takes a message written during a turn at its next tool boundary. If the turn ends first, the message starts
|
|
141
|
+
* a turn of its own; its replay arriving before or after the turn's result tells which happened.
|
|
142
|
+
*/
|
|
143
|
+
steer(message) {
|
|
144
|
+
if (!this.current)
|
|
145
|
+
return Promise.resolve('notSent');
|
|
146
|
+
return new Promise(resolve => {
|
|
147
|
+
if (!this.write(message.text, outcome => {
|
|
148
|
+
if (outcome === 'steered')
|
|
149
|
+
this.submitted.delete(message.id);
|
|
150
|
+
resolve(outcome);
|
|
151
|
+
}))
|
|
152
|
+
return resolve('notSent');
|
|
153
|
+
this.submitted.add(message.id);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
write(text, steered) {
|
|
157
|
+
const sent = this.process?.write(JSON.stringify({
|
|
158
|
+
type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null, session_id: this.sessionId ?? '',
|
|
159
|
+
}));
|
|
160
|
+
if (sent)
|
|
161
|
+
this.echoes.push({ text, steered });
|
|
162
|
+
return Boolean(sent);
|
|
163
|
+
}
|
|
164
|
+
replayed(content) {
|
|
165
|
+
const text = typeof content === 'string' ? content
|
|
166
|
+
: Array.isArray(content) ? content.map((block) => typeof block?.text === 'string' ? block.text : '').join('') : '';
|
|
167
|
+
const index = this.echoes.findIndex(echo => echo.text === text);
|
|
168
|
+
if (index < 0)
|
|
169
|
+
return;
|
|
170
|
+
const [echo] = this.echoes.splice(index, 1);
|
|
171
|
+
echo.steered?.(this.current ? 'steered' : 'nextTurn');
|
|
172
|
+
}
|
|
173
|
+
async interrupt() {
|
|
174
|
+
if (!this.current)
|
|
175
|
+
return;
|
|
176
|
+
this.current.interrupted = true;
|
|
177
|
+
await this.control({ subtype: 'interrupt' }).catch(() => { });
|
|
178
|
+
}
|
|
179
|
+
async close() {
|
|
180
|
+
this.closing = true;
|
|
181
|
+
await this.process?.kill();
|
|
182
|
+
if (this.directory)
|
|
183
|
+
await rm(this.directory, { recursive: true, force: true }).catch(() => { });
|
|
184
|
+
}
|
|
185
|
+
control(request) {
|
|
186
|
+
const requestId = randomUUID();
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
this.controls.set(requestId, { resolve, reject });
|
|
189
|
+
if (!this.process?.write(JSON.stringify({ type: 'control_request', request_id: requestId, request }))) {
|
|
190
|
+
this.controls.delete(requestId);
|
|
191
|
+
reject(new Error('Claude Code is not accepting input.'));
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
adopt(sessionId) {
|
|
196
|
+
if (sessionId === this.sessionId)
|
|
197
|
+
return;
|
|
198
|
+
this.sessionId = sessionId;
|
|
199
|
+
this.context.thread(sessionId);
|
|
200
|
+
}
|
|
201
|
+
emit(item) {
|
|
202
|
+
this.items.set(item.key, item);
|
|
203
|
+
this.context.item(item);
|
|
204
|
+
}
|
|
205
|
+
receive(line) {
|
|
206
|
+
let frame;
|
|
207
|
+
try {
|
|
208
|
+
frame = JSON.parse(line);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
switch (frame.type) {
|
|
214
|
+
case 'control_response': {
|
|
215
|
+
const response = frame.response ?? {};
|
|
216
|
+
const waiter = this.controls.get(response.request_id);
|
|
217
|
+
if (!waiter)
|
|
218
|
+
return;
|
|
219
|
+
this.controls.delete(response.request_id);
|
|
220
|
+
if (response.subtype === 'error')
|
|
221
|
+
waiter.reject(new Error(String(response.error ?? 'Claude Code rejected the request.')));
|
|
222
|
+
else
|
|
223
|
+
waiter.resolve(response.response ?? {});
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
case 'control_request':
|
|
227
|
+
// Permission prompts never reach a session: it runs without prompts or with a read-only allowlist.
|
|
228
|
+
this.process?.write(JSON.stringify({ type: 'control_response',
|
|
229
|
+
response: { subtype: 'error', request_id: frame.request_id, error: 'Skillshare sessions do not answer this request.' } }));
|
|
230
|
+
return;
|
|
231
|
+
case 'system':
|
|
232
|
+
if (frame.subtype === 'init' && typeof frame.session_id === 'string')
|
|
233
|
+
this.adopt(frame.session_id);
|
|
234
|
+
return;
|
|
235
|
+
case 'stream_event':
|
|
236
|
+
return this.streamEvent(frame.event ?? {});
|
|
237
|
+
case 'assistant':
|
|
238
|
+
return this.assistant(frame.message ?? {});
|
|
239
|
+
case 'user':
|
|
240
|
+
return frame.isReplay === true ? this.replayed(frame.message?.content) : this.toolResults(frame.message?.content);
|
|
241
|
+
case 'result':
|
|
242
|
+
return this.result(frame);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/** Text arrives in deltas first; the complete assistant frame later replaces it under the same key. */
|
|
246
|
+
streamEvent(event) {
|
|
247
|
+
if (event.type === 'message_start' && typeof event.message?.id === 'string')
|
|
248
|
+
this.messageId = event.message.id;
|
|
249
|
+
if (event.type !== 'content_block_delta' || !this.messageId)
|
|
250
|
+
return;
|
|
251
|
+
const delta = event.delta ?? {};
|
|
252
|
+
const kind = delta.type === 'text_delta' ? 'assistant' : delta.type === 'thinking_delta' ? 'reasoning' : null;
|
|
253
|
+
if (!kind)
|
|
254
|
+
return;
|
|
255
|
+
const key = `${this.messageId}:${event.index}`;
|
|
256
|
+
const text = (this.streamed.get(key) ?? '') + String(delta.text ?? delta.thinking ?? '');
|
|
257
|
+
this.streamed.set(key, text);
|
|
258
|
+
if (text.trim())
|
|
259
|
+
this.emit({ key, kind, status: 'inProgress', text });
|
|
260
|
+
}
|
|
261
|
+
assistant(message) {
|
|
262
|
+
const id = String(message.id ?? '');
|
|
263
|
+
for (const block of Array.isArray(message.content) ? message.content : []) {
|
|
264
|
+
const index = this.blocks.get(id) ?? 0;
|
|
265
|
+
this.blocks.set(id, index + 1);
|
|
266
|
+
const key = `${id}:${index}`;
|
|
267
|
+
if (block.type === 'text' && typeof block.text === 'string')
|
|
268
|
+
this.emit({ key, kind: 'assistant', status: 'completed', text: block.text });
|
|
269
|
+
else if (block.type === 'thinking' && typeof block.thinking === 'string' && block.thinking.trim())
|
|
270
|
+
this.emit({ key, kind: 'reasoning', status: 'completed', text: block.thinking });
|
|
271
|
+
else if (block.type === 'tool_use' && block.id)
|
|
272
|
+
this.emit(claudeToolItem(block));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
toolResults(content) {
|
|
276
|
+
if (!Array.isArray(content))
|
|
277
|
+
return;
|
|
278
|
+
for (const block of content) {
|
|
279
|
+
if (block?.type !== 'tool_result')
|
|
280
|
+
continue;
|
|
281
|
+
const item = this.items.get(String(block.tool_use_id));
|
|
282
|
+
if (item && item.status === 'inProgress')
|
|
283
|
+
this.emit({ ...item, status: block.is_error ? 'failed' : 'completed' });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
result(frame) {
|
|
287
|
+
if (typeof frame.session_id === 'string')
|
|
288
|
+
this.adopt(frame.session_id);
|
|
289
|
+
const current = this.current;
|
|
290
|
+
this.current = null;
|
|
291
|
+
const record = (outcome) => current ? current.resolve(outcome) : this.early.push(outcome);
|
|
292
|
+
// Costs are totals for the process; a turn's share is the difference.
|
|
293
|
+
const cost = typeof frame.total_cost_usd === 'number' ? frame.total_cost_usd : undefined;
|
|
294
|
+
const turnCost = cost === undefined ? undefined : Math.max(0, cost - this.costSoFar);
|
|
295
|
+
if (cost !== undefined)
|
|
296
|
+
this.costSoFar = cost;
|
|
297
|
+
const usage = frame.usage ?? {};
|
|
298
|
+
const input = [usage.input_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens].filter((n) => typeof n === 'number');
|
|
299
|
+
const count = (value) => typeof value === 'number' ? value : undefined;
|
|
300
|
+
const stats = { costUsd: turnCost, inputTokens: input.length > 0 ? input.reduce((a, b) => a + b, 0) : undefined,
|
|
301
|
+
cachedInputTokens: count(usage.cache_read_input_tokens), cacheWriteTokens: count(usage.cache_creation_input_tokens), outputTokens: count(usage.output_tokens) };
|
|
302
|
+
if (frame.subtype === 'success' && frame.is_error !== true)
|
|
303
|
+
return record({ status: 'completed', stats });
|
|
304
|
+
if (current?.interrupted)
|
|
305
|
+
return record({ status: 'interrupted', stats });
|
|
306
|
+
const detail = typeof frame.result === 'string' && frame.result ? frame.result
|
|
307
|
+
: Array.isArray(frame.errors) && frame.errors.length > 0 ? frame.errors.join(' ') : `Claude Code ended the turn with ${frame.subtype ?? 'an error'}.`;
|
|
308
|
+
record({ status: 'failed', error: clip(detail, 2000), stats });
|
|
309
|
+
}
|
|
310
|
+
onExit(code) {
|
|
311
|
+
const reason = `Claude Code exited${code === null ? '' : ` with code ${code}`}${this.process?.stderrTail() ? `: ${this.process.stderrTail()}` : '.'}`;
|
|
312
|
+
for (const waiter of this.controls.values())
|
|
313
|
+
waiter.reject(new Error(reason));
|
|
314
|
+
this.controls.clear();
|
|
315
|
+
for (const echo of this.echoes.splice(0))
|
|
316
|
+
echo.steered?.('notSent');
|
|
317
|
+
this.current?.resolve({ status: 'failed', error: reason });
|
|
318
|
+
this.current = null;
|
|
319
|
+
if (!this.closing)
|
|
320
|
+
this.context.exited(reason);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
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, cached: 0, cacheWrite: 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
|
+
/** turn/steer adds input to the active turn; Codex refuses it once that turn is over, and the message then runs as the next turn. */
|
|
147
|
+
async steer(message) {
|
|
148
|
+
const turnId = this.current?.id;
|
|
149
|
+
if (!turnId || !this.threadId)
|
|
150
|
+
return 'notSent';
|
|
151
|
+
try {
|
|
152
|
+
await this.request('turn/steer', { threadId: this.threadId, expectedTurnId: turnId, input: [{ type: 'text', text: message.text }] });
|
|
153
|
+
return 'steered';
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return 'notSent';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async close() {
|
|
160
|
+
this.closing = true;
|
|
161
|
+
await this.process?.kill();
|
|
162
|
+
}
|
|
163
|
+
request(method, params) {
|
|
164
|
+
if (!this.process)
|
|
165
|
+
return Promise.reject(new Error('Codex is not running.'));
|
|
166
|
+
const id = ++this.nextId;
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
this.pending.set(id, { resolve, reject });
|
|
169
|
+
if (!this.process.write(JSON.stringify({ id, method, params }))) {
|
|
170
|
+
this.pending.delete(id);
|
|
171
|
+
reject(new Error('Codex is not accepting input.'));
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
notify(method, params) {
|
|
176
|
+
this.process?.write(JSON.stringify(params === undefined ? { method } : { method, params }));
|
|
177
|
+
}
|
|
178
|
+
receive(line) {
|
|
179
|
+
let message;
|
|
180
|
+
try {
|
|
181
|
+
message = JSON.parse(line);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (message.id !== undefined && typeof message.method === 'string')
|
|
187
|
+
return this.answer(message);
|
|
188
|
+
if (message.id !== undefined) {
|
|
189
|
+
const pending = this.pending.get(message.id);
|
|
190
|
+
if (!pending)
|
|
191
|
+
return;
|
|
192
|
+
this.pending.delete(message.id);
|
|
193
|
+
if (message.error)
|
|
194
|
+
pending.reject(new Error(codexErrorText(message.error.message) ?? 'Codex rejected the request.'));
|
|
195
|
+
else
|
|
196
|
+
pending.resolve(message.result);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (typeof message.method === 'string')
|
|
200
|
+
this.notification(message.method, message.params ?? {});
|
|
201
|
+
}
|
|
202
|
+
/** Codex asks the client only for approvals and input, which a session never gives: the approval policy is never. */
|
|
203
|
+
answer(message) {
|
|
204
|
+
const reply = /requestApproval$/.test(message.method) ? { id: message.id, result: { decision: 'decline' } }
|
|
205
|
+
: { id: message.id, error: { code: -32601, message: 'Skillshare sessions do not answer this request.' } };
|
|
206
|
+
this.process?.write(JSON.stringify(reply));
|
|
207
|
+
}
|
|
208
|
+
notification(method, params) {
|
|
209
|
+
// Subagent threads report on the same connection; only the session's own thread is transcribed.
|
|
210
|
+
if (typeof params.threadId === 'string' && this.threadId && params.threadId !== this.threadId)
|
|
211
|
+
return;
|
|
212
|
+
switch (method) {
|
|
213
|
+
case 'item/started':
|
|
214
|
+
case 'item/completed': {
|
|
215
|
+
const item = codexItem(params.item);
|
|
216
|
+
if (!item)
|
|
217
|
+
return;
|
|
218
|
+
if (item.kind === 'assistant' || item.kind === 'reasoning') {
|
|
219
|
+
if (method === 'item/completed' || item.text)
|
|
220
|
+
this.texts.set(item.key, item.text ?? '');
|
|
221
|
+
else
|
|
222
|
+
item.text = this.texts.get(item.key) ?? '';
|
|
223
|
+
item.status = method === 'item/completed' ? 'completed' : 'inProgress';
|
|
224
|
+
}
|
|
225
|
+
this.context.item(item);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
case 'item/agentMessage/delta':
|
|
229
|
+
case 'item/reasoning/summaryTextDelta': {
|
|
230
|
+
const key = String(params.itemId ?? '');
|
|
231
|
+
if (!key)
|
|
232
|
+
return;
|
|
233
|
+
const text = (this.texts.get(key) ?? '') + String(params.delta ?? '');
|
|
234
|
+
this.texts.set(key, text);
|
|
235
|
+
this.context.item({ key, kind: method === 'item/agentMessage/delta' ? 'assistant' : 'reasoning', status: 'inProgress', text });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
case 'turn/plan/updated': {
|
|
239
|
+
const steps = Array.isArray(params.plan) ? params.plan : [];
|
|
240
|
+
const text = steps.map((step) => `- [${step.status === 'completed' ? 'x' : ' '}] ${step.step ?? ''}`).join('\n');
|
|
241
|
+
if (text && params.turnId)
|
|
242
|
+
this.context.item({ key: `plan-${params.turnId}`, kind: 'plan', text: clip(text) });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
case 'thread/tokenUsage/updated': {
|
|
246
|
+
// total covers the whole thread, including turns from before a resume; last is one model request, so a turn adds them up.
|
|
247
|
+
const last = params.tokenUsage?.last;
|
|
248
|
+
if (last && this.current) {
|
|
249
|
+
this.current.usage.input += Number(last.inputTokens) || 0;
|
|
250
|
+
this.current.usage.cached += Number(last.cachedInputTokens) || 0;
|
|
251
|
+
this.current.usage.cacheWrite += Number(last.cacheWriteInputTokens) || 0;
|
|
252
|
+
this.current.usage.output += Number(last.outputTokens) || 0;
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
case 'error': {
|
|
257
|
+
const text = codexErrorText(params.error?.message);
|
|
258
|
+
if (this.current && text)
|
|
259
|
+
this.current.error = text;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
case 'turn/started':
|
|
263
|
+
if (this.current && typeof params.turn?.id === 'string')
|
|
264
|
+
this.current.id ??= params.turn.id;
|
|
265
|
+
return;
|
|
266
|
+
case 'turn/completed': {
|
|
267
|
+
const turn = params.turn ?? {};
|
|
268
|
+
const current = this.current;
|
|
269
|
+
if (!current || current.id && turn.id && turn.id !== current.id)
|
|
270
|
+
return;
|
|
271
|
+
this.current = null;
|
|
272
|
+
const status = turn.status === 'completed' ? 'completed' : turn.status === 'interrupted' ? 'interrupted' : 'failed';
|
|
273
|
+
const { usage } = current;
|
|
274
|
+
const stats = { inputTokens: usage.input, cachedInputTokens: usage.cached, cacheWriteTokens: usage.cacheWrite, outputTokens: usage.output };
|
|
275
|
+
current.resolve(status === 'failed'
|
|
276
|
+
? { status, error: codexErrorText(turn.error?.message) ?? current.error ?? 'The turn failed.', stats }
|
|
277
|
+
: { status, stats });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
onExit(code) {
|
|
283
|
+
const reason = `Codex exited${code === null ? '' : ` with code ${code}`}${this.process?.stderrTail() ? `: ${this.process.stderrTail()}` : '.'}`;
|
|
284
|
+
for (const pending of this.pending.values())
|
|
285
|
+
pending.reject(new Error(reason));
|
|
286
|
+
this.pending.clear();
|
|
287
|
+
this.current?.resolve({ status: 'failed', error: reason });
|
|
288
|
+
this.current = null;
|
|
289
|
+
if (!this.closing)
|
|
290
|
+
this.context.exited(reason);
|
|
291
|
+
}
|
|
292
|
+
}
|