@quadient/skillshare 1.8.1 → 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 CHANGED
@@ -19,6 +19,41 @@ bun run index.ts install 12 0.1.0 --target ./installed
19
19
  bun run index.ts install --skill-version-id 42 --target ./installed
20
20
  ```
21
21
 
22
+ ## Connected agents
23
+
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,
29
+ without permission prompts.
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
+
35
+ ```sh
36
+ skillshare agents status
37
+ skillshare agents listen --workdir ~/Research
38
+ skillshare agents listen --name desktop --max-concurrency 2
39
+ ```
40
+
41
+ `--sandbox` keeps every agent read-only. Claude Code may only read files, search the web
42
+ and use the Skillshare MCP; Codex runs in its read-only sandbox. A profile can do the same
43
+ with `"sandbox": true`.
44
+
45
+ Optional named profiles in `~/.config/skillshare/agents.json` set a working directory and
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
48
+ segment are sent to Skillshare.
49
+
50
+ ```json
51
+ { "profiles": [
52
+ { "name": "piq-opus-high", "agent": "claude", "model": "opus", "reasoning": "high",
53
+ "workdir": "~/Research/piq-modules", "args": ["--add-dir", "~/Research/piq-projects"] }
54
+ ] }
55
+ ```
56
+
22
57
  ## Teams MCP
23
58
 
24
59
  Starting with 1.7.0, `skillshare teams login` opens a dedicated local browser profile,
@@ -54,6 +89,13 @@ permissions, Microsoft session lifetime and file-tool differences in the hosted
54
89
 
55
90
  ## Behavior
56
91
 
92
+ Starting with 1.9.0:
93
+
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.
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.
96
+ - `show` accepts a skill name as well as an id.
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.
98
+
57
99
  Starting with 1.8.0:
58
100
 
59
101
  - `install` and `update` download into a staging directory first, so a failed transfer leaves the existing install untouched. Files that a new version no longer contains are removed.
@@ -213,7 +255,7 @@ npx @quadient/skillshare update 12 --target ./installed
213
255
 
214
256
  Publish an authenticated static page. The upload replaces the page's complete
215
257
  content, and the directory must contain `index.html` at its root. By default the
216
- directory name is used as the page slug:
258
+ directory name is used as the page slug; build output folders such as `dist` need `--slug`:
217
259
 
218
260
  ```bash
219
261
  npx @quadient/skillshare pages upload ./release-notes
@@ -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
+ }