@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 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`) tasks
25
- on this computer. The listener reports the installed agents with their models and reasoning
26
- levels. Choose which computer and configuration runs each kind of task under **Connected
27
- agents** in Skillshare. Agents run with your full local permissions and configuration,
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. Only the name, agent, model, reasoning and the working directory's last
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 tasks from Skillshare on this computer (see [Connected agents](#connected-agents)); `--sandbox` keeps them read-only.
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
+ }