@quadient/skillshare 1.10.1 → 1.10.2

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
@@ -30,7 +30,9 @@ without permission prompts.
30
30
 
31
31
  Codex runs as `codex app-server` and Claude Code with stream-json input and output. The agent
32
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.
33
+ the same Codex thread or Claude Code session. Sessions need CLI 1.10.0 or newer. Images
34
+ attached to messages reach the agent from CLI 1.10.2: the listener downloads them to a temp
35
+ directory that it removes when the session closes.
34
36
 
35
37
  ```sh
36
38
  skillshare agents status
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
2
3
  import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
4
  import { tmpdir } from 'node:os';
4
5
  import { join } from 'node:path';
@@ -9,6 +10,20 @@ import { CLAUDE_SANDBOX_TOOLS, DEFAULT, SANDBOX_NOTE, clip, isSandboxed, mcpServ
9
10
  * user's own claude install, so their login and settings apply. MCP config and the session's instructions are passed per
10
11
  * process; tokens only through the environment.
11
12
  */
13
+ /** Plain text, or with images the content blocks of the message: its text, then each downloaded image inline as base64. */
14
+ export function claudeContent(message) {
15
+ const images = (message.images ?? []).flatMap(image => {
16
+ try {
17
+ return [{ type: 'image', source: { type: 'base64', media_type: image.mediaType, data: readFileSync(image.path).toString('base64') } }];
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ });
23
+ if (images.length === 0)
24
+ return message.text;
25
+ return [...(message.text ? [{ type: 'text', text: message.text }] : []), ...images];
26
+ }
12
27
  export function claudeArgs(context, mcpConfigPath) {
13
28
  const { start } = context;
14
29
  const sandbox = isSandboxed(start.configuration, context);
@@ -130,7 +145,7 @@ class ClaudeDriver {
130
145
  return;
131
146
  }
132
147
  this.current = { resolve, interrupted: false };
133
- if (!this.write(message.text)) {
148
+ if (!this.write(message)) {
134
149
  this.current = null;
135
150
  reject(new Error('Claude Code is not accepting input.'));
136
151
  }
@@ -144,7 +159,7 @@ class ClaudeDriver {
144
159
  if (!this.current)
145
160
  return Promise.resolve('notSent');
146
161
  return new Promise(resolve => {
147
- if (!this.write(message.text, outcome => {
162
+ if (!this.write(message, outcome => {
148
163
  if (outcome === 'steered')
149
164
  this.submitted.delete(message.id);
150
165
  resolve(outcome);
@@ -153,12 +168,12 @@ class ClaudeDriver {
153
168
  this.submitted.add(message.id);
154
169
  });
155
170
  }
156
- write(text, steered) {
171
+ write(message, steered) {
157
172
  const sent = this.process?.write(JSON.stringify({
158
- type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null, session_id: this.sessionId ?? '',
173
+ type: 'user', message: { role: 'user', content: claudeContent(message) }, parent_tool_use_id: null, session_id: this.sessionId ?? '',
159
174
  }));
160
175
  if (sent)
161
- this.echoes.push({ text, steered });
176
+ this.echoes.push({ text: message.text, steered });
162
177
  return Boolean(sent);
163
178
  }
164
179
  replayed(content) {
@@ -13,6 +13,13 @@ export function codexConfigArgs(args) {
13
13
  return kept;
14
14
  }
15
15
  /** thread/start and thread/resume share these: where to work, what may run, the session's MCP servers and instructions. */
16
+ /** The message as Codex user input: its text, then each downloaded image as a local file Codex reads itself. */
17
+ export function codexInput(message) {
18
+ return [
19
+ ...(message.text ? [{ type: 'text', text: message.text }] : []),
20
+ ...(message.images ?? []).filter(image => image.path).map(image => ({ type: 'localImage', path: image.path })),
21
+ ];
22
+ }
16
23
  export function codexThreadParams(context) {
17
24
  const { start } = context;
18
25
  const sandbox = isSandboxed(start.configuration, context);
@@ -128,7 +135,7 @@ class CodexDriver {
128
135
  return new Promise((resolve, reject) => {
129
136
  this.current = { resolve, usage: { input: 0, cached: 0, cacheWrite: 0, output: 0 } };
130
137
  this.request('turn/start', {
131
- threadId: this.threadId, input: [{ type: 'text', text: message.text }],
138
+ threadId: this.threadId, input: codexInput(message),
132
139
  ...(configuration.reasoning !== DEFAULT ? { effort: configuration.reasoning } : {}),
133
140
  }).then(result => {
134
141
  if (this.current && typeof result?.turn?.id === 'string')
@@ -149,7 +156,7 @@ class CodexDriver {
149
156
  if (!turnId || !this.threadId)
150
157
  return 'notSent';
151
158
  try {
152
- await this.request('turn/steer', { threadId: this.threadId, expectedTurnId: turnId, input: [{ type: 'text', text: message.text }] });
159
+ await this.request('turn/steer', { threadId: this.threadId, expectedTurnId: turnId, input: codexInput(message) });
153
160
  return 'steered';
154
161
  }
155
162
  catch {
package/dist/agents.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
- import { hostname, platform } from 'node:os';
2
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { hostname, platform, tmpdir } from 'node:os';
4
4
  import { dirname, join, resolve } from 'node:path';
5
5
  import { setTimeout as delay } from 'node:timers/promises';
6
6
  import { createClaudeDriver } from './agents-claude.js';
7
7
  import { createCodexDriver } from './agents-codex.js';
8
8
  import { detectAgents, profilesPath } from './agents-detect.js';
9
- import { SessionRunner } from './agents-session.js';
9
+ import { SessionRunner, errorMessage, } from './agents-session.js';
10
10
  import { readCliVersion } from './help.js';
11
11
  const root = '/api/agents';
12
12
  const PROFILE_HINT = 'Declare named profiles in ' + profilesPath() + ' to set a working directory or extra arguments.';
@@ -29,6 +29,41 @@ async function send(client, method, path, body, signal) {
29
29
  }
30
30
  return response.status === 204 ? undefined : await response.json();
31
31
  }
32
+ const MAX_IMAGE_BYTES = 6 * 1024 * 1024;
33
+ const IMAGE_ID = /^[0-9a-f]{32}\.(png|jpg|gif|webp)$/;
34
+ /**
35
+ * Downloads the images of each message into the session's directory, so the agent reads them as local files. An image that
36
+ * cannot be fetched is left out and the agent is told, so the turn still runs.
37
+ */
38
+ export async function downloadImages(client, directory, messages) {
39
+ return Promise.all(messages.map(async (message) => {
40
+ if (!message.images?.length)
41
+ return message;
42
+ const images = [];
43
+ const problems = [];
44
+ for (const image of message.images) {
45
+ try {
46
+ if (!IMAGE_ID.test(image.id) || !image.url.startsWith(`${root}/sessions/`))
47
+ throw new Error('unexpected image link');
48
+ const response = await client.request(image.url, { method: 'GET' });
49
+ if (!response.ok)
50
+ throw new Error(`HTTP ${response.status}`);
51
+ const data = Buffer.from(await response.arrayBuffer());
52
+ if (data.length > MAX_IMAGE_BYTES)
53
+ throw new Error('the image is too large');
54
+ await mkdir(directory, { recursive: true });
55
+ const path = join(directory, image.id);
56
+ await writeFile(path, data);
57
+ images.push({ ...image, path });
58
+ }
59
+ catch (error) {
60
+ problems.push(errorMessage(error));
61
+ }
62
+ }
63
+ const note = problems.length ? `\n\n[${problems.length} attached image(s) could not be downloaded from Skillshare: ${problems.join('; ')}]` : '';
64
+ return { ...message, text: (message.text + note).trim(), images };
65
+ }));
66
+ }
32
67
  /** One stable listener id per Skillshare server and account, so the web keeps routes across restarts. */
33
68
  export async function loadListenerId(appDirectory, baseUrl, userId) {
34
69
  const key = createHash('sha256').update(baseUrl + '\0' + userId).digest('hex').slice(0, 24);
@@ -117,6 +152,9 @@ export async function listen(options) {
117
152
  const now = options.now ?? Date.now;
118
153
  const log = options.onProgress ?? (() => { });
119
154
  const sessions = new Map();
155
+ // Messages reach a session in order even while earlier ones still download their images.
156
+ const deliveries = new Map();
157
+ const imageRoot = options.imageDirectory ?? join(tmpdir(), 'skillshare-agents');
120
158
  let detected = await detect();
121
159
  let registeredFingerprint = '';
122
160
  let detectedAt = now();
@@ -162,18 +200,32 @@ export async function listen(options) {
162
200
  });
163
201
  sessions.set(command.sessionId, runner);
164
202
  log(`${command.start.resumeThreadId ? 'Resumed' : 'Started'} session ${command.sessionId} "${command.start.title}" with ${label(command.start)}.`);
165
- void runner.done.then(() => {
166
- if (sessions.get(command.sessionId) === runner)
203
+ void runner.done.then(async () => {
204
+ if (sessions.get(command.sessionId) === runner) {
167
205
  sessions.delete(command.sessionId);
206
+ deliveries.delete(command.sessionId);
207
+ await rm(join(imageRoot, command.sessionId), { recursive: true, force: true }).catch(() => { });
208
+ }
168
209
  log(`Closed session ${command.sessionId}.`);
169
210
  });
170
- runner.send(command.messages ?? []);
211
+ deliver(runner, command.messages ?? []);
212
+ }
213
+ function deliver(runner, messages) {
214
+ if (messages.length === 0)
215
+ return;
216
+ if (!messages.some(message => message.images?.length) && !deliveries.has(runner.sessionId))
217
+ return runner.send(messages);
218
+ const previous = deliveries.get(runner.sessionId) ?? Promise.resolve();
219
+ const next = previous.then(() => downloadImages(options.client, join(imageRoot, runner.sessionId), messages)).then(ready => runner.send(ready));
220
+ deliveries.set(runner.sessionId, next);
221
+ void next.finally(() => { if (deliveries.get(runner.sessionId) === next)
222
+ deliveries.delete(runner.sessionId); });
171
223
  }
172
224
  function handle(command) {
173
225
  const runner = sessions.get(command.sessionId);
174
226
  switch (command.type) {
175
227
  case 'start': return start(command);
176
- case 'send': return runner?.send(command.messages ?? []);
228
+ case 'send': return runner && deliver(runner, command.messages ?? []);
177
229
  case 'interrupt': return void runner?.interrupt();
178
230
  case 'close': return void runner?.close();
179
231
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quadient/skillshare",
3
- "version": "1.10.1",
3
+ "version": "1.10.2",
4
4
  "description": "Skillshare CLI for installing and publishing Quadient skills",
5
5
  "type": "module",
6
6
  "bin": {