@quadient/skillshare 1.9.0 โ†’ 1.10.0

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