@quadient/skillshare 1.10.0 → 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
@@ -59,7 +61,8 @@ segment are sent to Skillshare.
59
61
  Starting with 1.7.0, `skillshare teams login` opens a dedicated local browser profile,
60
62
  connects your own Teams account to Skillshare and verifies the connection. Sign in with
61
63
  the same Microsoft account as Skillshare. The server refreshes captured tokens in the
62
- background. `teams watch` renews an expired Microsoft grant through local browser SSO.
64
+ background. `teams watch` renews an expired Microsoft grant through local browser SSO; `agents listen` does the same
65
+ while it runs, so a computer that runs agents needs no separate `teams watch` (`--no-teams-watch` turns it off).
63
66
  Keep that process running for automatic recovery beyond the SPA refresh token lifetime;
64
67
  another interactive CLI login is needed only when SSO cannot complete, for example MFA.
65
68
 
@@ -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,11 +10,26 @@ 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);
15
30
  const servers = start.mcpServers ?? [];
16
- const args = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--include-partial-messages',
31
+ // Replayed user messages tell when Claude Code took a message, which is how a steered message is known to have joined the turn.
32
+ const args = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--include-partial-messages', '--replay-user-messages',
17
33
  ...(sandbox
18
34
  // A sandboxed session never prompts: anything outside the read-only allowlist is denied.
19
35
  ? ['--permission-mode', 'dontAsk', '--allowedTools', ...CLAUDE_SANDBOX_TOOLS, ...servers.map((server, index) => `mcp__${mcpServerName(server, index)}`)]
@@ -80,6 +96,12 @@ class ClaudeDriver {
80
96
  blocks = new Map();
81
97
  streamed = new Map();
82
98
  messageId = '';
99
+ /** Messages written to Claude Code whose replay has not come back yet, in order. */
100
+ echoes = [];
101
+ /** Steered messages Claude Code will run as their own turn; turn() then only waits for their outcome. */
102
+ submitted = new Set();
103
+ /** Outcomes of such turns that ended before turn() asked for them. */
104
+ early = [];
83
105
  costSoFar = 0;
84
106
  closing = false;
85
107
  constructor(context, options) {
@@ -114,16 +136,55 @@ class ClaudeDriver {
114
136
  }
115
137
  turn(message) {
116
138
  return new Promise((resolve, reject) => {
139
+ if (this.submitted.delete(message.id)) {
140
+ const early = this.early.shift();
141
+ if (early)
142
+ resolve(early);
143
+ else
144
+ this.current = { resolve, interrupted: false };
145
+ return;
146
+ }
117
147
  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) {
148
+ if (!this.write(message)) {
122
149
  this.current = null;
123
150
  reject(new Error('Claude Code is not accepting input.'));
124
151
  }
125
152
  });
126
153
  }
154
+ /**
155
+ * Claude Code takes a message written during a turn at its next tool boundary. If the turn ends first, the message starts
156
+ * a turn of its own; its replay arriving before or after the turn's result tells which happened.
157
+ */
158
+ steer(message) {
159
+ if (!this.current)
160
+ return Promise.resolve('notSent');
161
+ return new Promise(resolve => {
162
+ if (!this.write(message, outcome => {
163
+ if (outcome === 'steered')
164
+ this.submitted.delete(message.id);
165
+ resolve(outcome);
166
+ }))
167
+ return resolve('notSent');
168
+ this.submitted.add(message.id);
169
+ });
170
+ }
171
+ write(message, steered) {
172
+ const sent = this.process?.write(JSON.stringify({
173
+ type: 'user', message: { role: 'user', content: claudeContent(message) }, parent_tool_use_id: null, session_id: this.sessionId ?? '',
174
+ }));
175
+ if (sent)
176
+ this.echoes.push({ text: message.text, steered });
177
+ return Boolean(sent);
178
+ }
179
+ replayed(content) {
180
+ const text = typeof content === 'string' ? content
181
+ : Array.isArray(content) ? content.map((block) => typeof block?.text === 'string' ? block.text : '').join('') : '';
182
+ const index = this.echoes.findIndex(echo => echo.text === text);
183
+ if (index < 0)
184
+ return;
185
+ const [echo] = this.echoes.splice(index, 1);
186
+ echo.steered?.(this.current ? 'steered' : 'nextTurn');
187
+ }
127
188
  async interrupt() {
128
189
  if (!this.current)
129
190
  return;
@@ -191,7 +252,7 @@ class ClaudeDriver {
191
252
  case 'assistant':
192
253
  return this.assistant(frame.message ?? {});
193
254
  case 'user':
194
- return this.toolResults(frame.message?.content);
255
+ return frame.isReplay === true ? this.replayed(frame.message?.content) : this.toolResults(frame.message?.content);
195
256
  case 'result':
196
257
  return this.result(frame);
197
258
  }
@@ -242,8 +303,7 @@ class ClaudeDriver {
242
303
  this.adopt(frame.session_id);
243
304
  const current = this.current;
244
305
  this.current = null;
245
- if (!current)
246
- return;
306
+ const record = (outcome) => current ? current.resolve(outcome) : this.early.push(outcome);
247
307
  // Costs are totals for the process; a turn's share is the difference.
248
308
  const cost = typeof frame.total_cost_usd === 'number' ? frame.total_cost_usd : undefined;
249
309
  const turnCost = cost === undefined ? undefined : Math.max(0, cost - this.costSoFar);
@@ -251,21 +311,24 @@ class ClaudeDriver {
251
311
  this.costSoFar = cost;
252
312
  const usage = frame.usage ?? {};
253
313
  const input = [usage.input_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens].filter((n) => typeof n === 'number');
314
+ const count = (value) => typeof value === 'number' ? value : undefined;
254
315
  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 };
316
+ cachedInputTokens: count(usage.cache_read_input_tokens), cacheWriteTokens: count(usage.cache_creation_input_tokens), outputTokens: count(usage.output_tokens) };
256
317
  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 });
318
+ return record({ status: 'completed', stats });
319
+ if (current?.interrupted)
320
+ return record({ status: 'interrupted', stats });
260
321
  const detail = typeof frame.result === 'string' && frame.result ? frame.result
261
322
  : 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 });
323
+ record({ status: 'failed', error: clip(detail, 2000), stats });
263
324
  }
264
325
  onExit(code) {
265
326
  const reason = `Claude Code exited${code === null ? '' : ` with code ${code}`}${this.process?.stderrTail() ? `: ${this.process.stderrTail()}` : '.'}`;
266
327
  for (const waiter of this.controls.values())
267
328
  waiter.reject(new Error(reason));
268
329
  this.controls.clear();
330
+ for (const echo of this.echoes.splice(0))
331
+ echo.steered?.('notSent');
269
332
  this.current?.resolve({ status: 'failed', error: reason });
270
333
  this.current = null;
271
334
  if (!this.closing)
@@ -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);
@@ -126,9 +133,9 @@ class CodexDriver {
126
133
  turn(message) {
127
134
  const { configuration } = this.context.start;
128
135
  return new Promise((resolve, reject) => {
129
- this.current = { resolve, usage: { input: 0, output: 0 } };
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')
@@ -143,6 +150,19 @@ class CodexDriver {
143
150
  if (this.current?.id && this.threadId)
144
151
  await this.request('turn/interrupt', { threadId: this.threadId, turnId: this.current.id });
145
152
  }
153
+ /** turn/steer adds input to the active turn; Codex refuses it once that turn is over, and the message then runs as the next turn. */
154
+ async steer(message) {
155
+ const turnId = this.current?.id;
156
+ if (!turnId || !this.threadId)
157
+ return 'notSent';
158
+ try {
159
+ await this.request('turn/steer', { threadId: this.threadId, expectedTurnId: turnId, input: codexInput(message) });
160
+ return 'steered';
161
+ }
162
+ catch {
163
+ return 'notSent';
164
+ }
165
+ }
146
166
  async close() {
147
167
  this.closing = true;
148
168
  await this.process?.kill();
@@ -234,6 +254,8 @@ class CodexDriver {
234
254
  const last = params.tokenUsage?.last;
235
255
  if (last && this.current) {
236
256
  this.current.usage.input += Number(last.inputTokens) || 0;
257
+ this.current.usage.cached += Number(last.cachedInputTokens) || 0;
258
+ this.current.usage.cacheWrite += Number(last.cacheWriteInputTokens) || 0;
237
259
  this.current.usage.output += Number(last.outputTokens) || 0;
238
260
  }
239
261
  return;
@@ -255,7 +277,8 @@ class CodexDriver {
255
277
  return;
256
278
  this.current = null;
257
279
  const status = turn.status === 'completed' ? 'completed' : turn.status === 'interrupted' ? 'interrupted' : 'failed';
258
- const stats = { inputTokens: current.usage.input, outputTokens: current.usage.output };
280
+ const { usage } = current;
281
+ const stats = { inputTokens: usage.input, cachedInputTokens: usage.cached, cacheWriteTokens: usage.cacheWrite, outputTokens: usage.output };
259
282
  current.resolve(status === 'failed'
260
283
  ? { status, error: codexErrorText(turn.error?.message) ?? current.error ?? 'The turn failed.', stats }
261
284
  : { status, stats });
@@ -2,6 +2,7 @@ import { execFile } from 'node:child_process';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { basename, isAbsolute, join, resolve } from 'node:path';
5
+ import { startAgentProcess } from './agents-process.js';
5
6
  /** Model aliases Claude Code resolves to the latest release; Claude Code itself has no command that lists models. */
6
7
  export const CLAUDE_MODELS = ['fable', 'opus', 'sonnet', 'haiku'];
7
8
  export const CLAUDE_EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
@@ -30,19 +31,11 @@ export async function detectAgents(options = {}) {
30
31
  if (codexVersion !== null) {
31
32
  const codexHome = env.CODEX_HOME ?? join(home, '.codex');
32
33
  const warnings = [];
33
- const cache = parseJson(await read(join(codexHome, 'models_cache.json')));
34
- const models = Array.isArray(cache?.models) ? cache.models
35
- .filter((m) => m?.visibility === 'list' && typeof m.slug === 'string' && NAME.test(m.slug))
36
- .map((m) => {
37
- const levels = (Array.isArray(m.supported_reasoning_levels) ? m.supported_reasoning_levels : [])
38
- .map((level) => typeof level === 'string' ? level : level?.effort)
39
- .filter((level) => typeof level === 'string' && NAME.test(level) && level !== 'default');
40
- const fallback = typeof m.default_reasoning_level === 'string' && levels.includes(m.default_reasoning_level) ? m.default_reasoning_level : undefined;
41
- return { id: m.slug, displayName: typeof m.display_name === 'string' ? m.display_name.slice(0, 100) : undefined,
42
- reasoningLevels: levels.slice(0, 16), defaultReasoning: fallback };
43
- }).slice(0, 64) : [];
34
+ // Codex's own model/list reflects the account; its cache file is the fallback when the app-server cannot be asked.
35
+ const listed = await (options.codexModels ?? (() => codexModelList()))().catch(() => null);
36
+ const models = listed ?? cachedCodexModels(parseJson(await read(join(codexHome, 'models_cache.json'))));
44
37
  if (models.length === 0)
45
- warnings.push('No Codex model list found in models_cache.json. Run codex once to refresh it; only the default model is offered.');
38
+ warnings.push('Codex did not list its models. Run codex once to sign in or refresh it; only the default model is offered.');
46
39
  const configured = defaultCodexModel(await read(join(codexHome, 'config.toml')));
47
40
  if (configured && models.length > 0 && !models.some(m => m.id === configured))
48
41
  warnings.push(`The default model "${configured}" from config.toml is not in the Codex model list and may be rejected; pick a model explicitly.`);
@@ -59,6 +52,74 @@ export async function detectAgents(options = {}) {
59
52
  problems,
60
53
  };
61
54
  }
55
+ /** Keeps the list within what the server accepts: valid names, no "default", and bounded sizes. */
56
+ function codexModel(id, displayName, levels, fallback) {
57
+ if (typeof id !== 'string' || !NAME.test(id) || id === 'default')
58
+ return null;
59
+ const reasoningLevels = levels.filter((level) => typeof level === 'string' && NAME.test(level) && level !== 'default').slice(0, 16);
60
+ return { id, displayName: typeof displayName === 'string' ? displayName.slice(0, 100) : undefined, reasoningLevels,
61
+ defaultReasoning: typeof fallback === 'string' && reasoningLevels.includes(fallback) ? fallback : undefined };
62
+ }
63
+ /** Models from model/list of `codex app-server`, the same list the Codex UI offers; hidden models are left out. */
64
+ export function codexModelsFromList(data) {
65
+ return (Array.isArray(data) ? data : []).filter((m) => m && m.hidden !== true)
66
+ .map((m) => codexModel(m.id ?? m.model, m.displayName, (Array.isArray(m.supportedReasoningEfforts) ? m.supportedReasoningEfforts : []).map((e) => e?.reasoningEffort), m.defaultReasoningEffort))
67
+ .filter((m) => m !== null).slice(0, 64);
68
+ }
69
+ function cachedCodexModels(cache) {
70
+ return (Array.isArray(cache?.models) ? cache.models : []).filter((m) => m?.visibility === 'list')
71
+ .map((m) => codexModel(m.slug, m.display_name, (Array.isArray(m.supported_reasoning_levels) ? m.supported_reasoning_levels : []).map((level) => typeof level === 'string' ? level : level?.effort), m.default_reasoning_level))
72
+ .filter((m) => m !== null).slice(0, 64);
73
+ }
74
+ /** Asks a short-lived `codex app-server` for model/list, following pages, and gives up after the timeout. */
75
+ export async function codexModelList(options = {}) {
76
+ const pending = new Map();
77
+ let nextId = 0;
78
+ const agent = startAgentProcess({
79
+ command: options.command ?? 'codex', args: ['app-server'], cwd: homedir(), env: {}, spawnAgent: options.spawnAgent,
80
+ onLine: line => {
81
+ try {
82
+ const message = JSON.parse(line);
83
+ if (message.id !== undefined && message.method === undefined)
84
+ pending.get(message.id)?.(message);
85
+ }
86
+ catch { }
87
+ },
88
+ });
89
+ const request = (method, params) => new Promise((resolvePromise, reject) => {
90
+ const id = ++nextId;
91
+ pending.set(id, message => message.error ? reject(new Error(String(message.error.message))) : resolvePromise(message.result ?? {}));
92
+ if (!agent.write(JSON.stringify({ id, method, params })))
93
+ reject(new Error('Codex is not accepting input.'));
94
+ });
95
+ const ask = async () => {
96
+ await request('initialize', { clientInfo: { name: 'skillshare', title: 'Skillshare', version: '0.0.0' }, capabilities: { experimentalApi: true } });
97
+ agent.write(JSON.stringify({ method: 'initialized' }));
98
+ const data = [];
99
+ let cursor;
100
+ for (let page = 0; page < 10; page++) {
101
+ const result = await request('model/list', cursor ? { cursor } : {});
102
+ data.push(...(Array.isArray(result.data) ? result.data : []));
103
+ cursor = typeof result.nextCursor === 'string' && result.nextCursor ? result.nextCursor : undefined;
104
+ if (!cursor)
105
+ break;
106
+ }
107
+ return codexModelsFromList(data);
108
+ };
109
+ let timer;
110
+ try {
111
+ const models = await Promise.race([ask(), agent.exited.then(() => null),
112
+ new Promise(resolvePromise => { timer = setTimeout(() => resolvePromise(null), options.timeoutMs ?? 20_000); })]);
113
+ return models && models.length > 0 ? models : null;
114
+ }
115
+ catch {
116
+ return null;
117
+ }
118
+ finally {
119
+ clearTimeout(timer);
120
+ await agent.kill();
121
+ }
122
+ }
62
123
  export function parseProfiles(text, home, problems = []) {
63
124
  if (text === null)
64
125
  return [];
@@ -104,6 +104,7 @@ export class SessionRunner {
104
104
  driver;
105
105
  seen = new Set();
106
106
  pending = [];
107
+ steering = new Set();
107
108
  now;
108
109
  opened = null;
109
110
  openSucceeded = false;
@@ -134,16 +135,43 @@ export class SessionRunner {
134
135
  });
135
136
  }
136
137
  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
+ /**
139
+ * Queues messages for the next turns, or steers them into the running turn; the server may send a message again, so
140
+ * repeats are ignored.
141
+ */
138
142
  send(messages) {
139
143
  for (const message of messages) {
140
144
  if (this.seen.has(message.id))
141
145
  continue;
142
146
  this.seen.add(message.id);
143
- this.pending.push(message);
147
+ if (message.steer && this.turnRunning && this.openSucceeded && this.driver.steer)
148
+ this.steer(message);
149
+ else
150
+ this.pending.push(message);
144
151
  }
145
152
  void this.pump();
146
153
  }
154
+ /** A message that misses the running turn becomes the next one, ahead of anything queued after it. */
155
+ steer(message) {
156
+ const task = (async () => {
157
+ let outcome = 'notSent';
158
+ try {
159
+ outcome = await this.driver.steer(message);
160
+ }
161
+ catch { }
162
+ if (this.closing)
163
+ return;
164
+ if (outcome === 'steered')
165
+ this.events.push({ type: 'steered', messageId: message.id });
166
+ else
167
+ this.pending.unshift(message);
168
+ })();
169
+ this.steering.add(task);
170
+ void task.finally(() => {
171
+ this.steering.delete(task);
172
+ void this.pump();
173
+ });
174
+ }
147
175
  async interrupt() {
148
176
  if (!this.turnRunning || this.interruptRequested)
149
177
  return;
@@ -160,7 +188,8 @@ export class SessionRunner {
160
188
  return this.finish();
161
189
  }
162
190
  async pump() {
163
- if (this.turnRunning || this.closing)
191
+ // While a steer is still being placed it decides what runs next; it pumps again once it knows.
192
+ if (this.turnRunning || this.closing || this.steering.size > 0)
164
193
  return;
165
194
  const message = this.pending.shift();
166
195
  if (!message) {
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);
@@ -44,7 +79,7 @@ export async function loadListenerId(appDirectory, baseUrl, userId) {
44
79
  await writeFile(file, JSON.stringify({ id }, null, 2), { mode: 0o600 });
45
80
  return id;
46
81
  }
47
- export async function runAgentsCommand(client, args, flags, appDirectory) {
82
+ export async function runAgentsCommand(client, args, flags, appDirectory, teamsWatch) {
48
83
  const action = args[0];
49
84
  if (action === 'status') {
50
85
  const detected = await detectAgents();
@@ -74,10 +109,17 @@ export async function runAgentsCommand(client, args, flags, appDirectory) {
74
109
  ? 'Sandboxed: agents may read files, search and use the Skillshare MCP tools, but cannot change files or run commands that modify anything.'
75
110
  : 'Agents run with your full local permissions and configuration, without permission prompts. Use --sandbox to keep them read-only.');
76
111
  console.log('Sessions keep their agent running between messages and close it after 15 quiet minutes; the next message resumes it.');
112
+ // Teams 👀 sessions need a live Teams session, so the listener keeps it renewed unless told not to.
113
+ const teams = teamsWatch && flags['no-teams-watch'] !== 'true'
114
+ ? teamsWatch(abort.signal, message => console.log(`[teams] ${message}`))
115
+ .catch(error => console.log(`[teams] Teams renewal stopped: ${error instanceof Error ? error.message : String(error)}`))
116
+ : Promise.resolve();
77
117
  try {
78
118
  await listen({ client, listenerId, name: flags.name, workdir, maxConcurrency: max, sandbox, signal: abort.signal, onProgress: console.log });
79
119
  }
80
120
  finally {
121
+ abort.abort();
122
+ await teams;
81
123
  process.removeListener('SIGINT', stop);
82
124
  process.removeListener('SIGTERM', stop);
83
125
  }
@@ -110,6 +152,9 @@ export async function listen(options) {
110
152
  const now = options.now ?? Date.now;
111
153
  const log = options.onProgress ?? (() => { });
112
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');
113
158
  let detected = await detect();
114
159
  let registeredFingerprint = '';
115
160
  let detectedAt = now();
@@ -155,18 +200,32 @@ export async function listen(options) {
155
200
  });
156
201
  sessions.set(command.sessionId, runner);
157
202
  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)
203
+ void runner.done.then(async () => {
204
+ if (sessions.get(command.sessionId) === runner) {
160
205
  sessions.delete(command.sessionId);
206
+ deliveries.delete(command.sessionId);
207
+ await rm(join(imageRoot, command.sessionId), { recursive: true, force: true }).catch(() => { });
208
+ }
161
209
  log(`Closed session ${command.sessionId}.`);
162
210
  });
163
- 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); });
164
223
  }
165
224
  function handle(command) {
166
225
  const runner = sessions.get(command.sessionId);
167
226
  switch (command.type) {
168
227
  case 'start': return start(command);
169
- case 'send': return runner?.send(command.messages ?? []);
228
+ case 'send': return runner && deliver(runner, command.messages ?? []);
170
229
  case 'interrupt': return void runner?.interrupt();
171
230
  case 'close': return void runner?.close();
172
231
  }
package/dist/help.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  export const GLOBAL_FLAGS = ['token', 'server', 'help'];
3
- export const BOOLEAN_FLAGS = new Set(['dry-run', 'force', 'headless', 'help', 'json', 'sandbox']);
3
+ export const BOOLEAN_FLAGS = new Set(['dry-run', 'force', 'headless', 'help', 'json', 'no-teams-watch', 'sandbox']);
4
4
  export const COMMANDS = [
5
5
  {
6
6
  name: 'login',
@@ -110,14 +110,14 @@ export const COMMANDS = [
110
110
  },
111
111
  {
112
112
  name: 'agents',
113
- summary: 'Run Claude Code or Codex tasks from Skillshare on this computer',
113
+ summary: 'Run Claude Code or Codex sessions from Skillshare on this computer',
114
114
  usage: [
115
- 'agents listen [local] [--name <name>] [--workdir <directory>] [--max-concurrency <1-4>] [--sandbox]',
115
+ 'agents listen [local] [--name <name>] [--workdir <directory>] [--max-concurrency <1-4>] [--sandbox] [--no-teams-watch]',
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. 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.',
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. With a Teams connection, the listener also keeps the Teams session renewed through local browser SSO, as skillshare teams watch does; --no-teams-watch turns that off.',
119
119
  examples: ['skillshare agents status', 'skillshare agents listen --workdir ~/Research'],
120
- flags: ['name', 'workdir', 'max-concurrency', 'sandbox', 'json'],
120
+ flags: ['name', 'workdir', 'max-concurrency', 'sandbox', 'no-teams-watch', 'browser', 'headless', 'browser-profile', 'json'],
121
121
  },
122
122
  {
123
123
  name: 'git',
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { assertCredentialDestination, resolveTeamsObjectId, runTeamsCommand } from './teams.js';
1
+ import { assertCredentialDestination, resolveTeamsObjectId, runTeamsCommand, watchTeamsInBackground } from './teams.js';
2
2
  import { runAgentsCommand } from './agents.js';
3
3
  import { formatUnknownCommand, parseCliArgs } from './cli-args.js';
4
4
  import { findCommand, readCliVersion, renderCommandHelp, renderUsage } from './help.js';
@@ -149,7 +149,8 @@ async function main() {
149
149
  assertCredentialDestination(baseUrl);
150
150
  if (!options.token && !await loadSession(baseUrl))
151
151
  await login(baseUrl);
152
- await runAgentsCommand(await createClient(baseUrl, options.token), options.positionals, options.flags, APP_DIR);
152
+ const agentsClient = await createClient(baseUrl, options.token);
153
+ await runAgentsCommand(agentsClient, options.positionals, options.flags, APP_DIR, (signal, log) => watchTeamsInBackground(agentsClient, options.flags, APP_DIR, signal, log));
153
154
  return;
154
155
  }
155
156
  case 'git': {
package/dist/teams.js CHANGED
@@ -29,6 +29,44 @@ async function expectOk(response) {
29
29
  catch { }
30
30
  throw new Error(error.error ?? `Skillshare Teams API returned HTTP ${response.status}.`);
31
31
  }
32
+ /**
33
+ * Captures a Teams session from the local browser profile and imports it. A watcher renews headless with a deadline and,
34
+ * given the connection it saw, imports conditionally, so a disconnect or another login in the meantime is not overwritten.
35
+ */
36
+ function teamsConnector(client, flags, appDirectory, user, objectId, watching, signal, log) {
37
+ const key = createHash('sha256').update(client.baseUrl + '\0' + user.id).digest('hex').slice(0, 24);
38
+ const profileDirectory = flags['browser-profile'] ? resolve(flags['browser-profile']) : join(appDirectory, 'teams', key, 'browser-profile');
39
+ return async (connectionId) => {
40
+ const session = await captureTeamsLogin({ profileDirectory, objectId, browser: flags.browser,
41
+ headless: watching ? flags.headless !== 'false' : flags.headless === 'true', recover: watching,
42
+ signal, timeoutMs: watching ? 90_000 : undefined, onProgress: log });
43
+ const headers = { 'Content-Type': 'application/json' };
44
+ if (connectionId)
45
+ headers['If-Match'] = `"${connectionId}"`;
46
+ const response = await client.request(`${root}/session`, { method: 'PUT', headers, body: JSON.stringify(session), signal });
47
+ await expectOk(response);
48
+ await client.post(`${root}/test`);
49
+ };
50
+ }
51
+ /**
52
+ * Keeps an existing Teams connection renewed while another command runs, exactly like `teams watch`. Returns at once when
53
+ * the account has no Teams connection, so commands can always start it.
54
+ */
55
+ export async function watchTeamsInBackground(client, flags, appDirectory, signal, log) {
56
+ assertCredentialDestination(client.baseUrl);
57
+ const status = await client.get(`${root}/settings`).catch(() => null);
58
+ if (!status?.connectionId)
59
+ return;
60
+ const user = await client.get('/api/auth/me');
61
+ const objectId = resolveTeamsObjectId(user);
62
+ if (!objectId) {
63
+ log(UNLINKED_ACCOUNT_MESSAGE);
64
+ return;
65
+ }
66
+ log('Keeping your Teams session renewed through local browser SSO, as skillshare teams watch does. Use --no-teams-watch to turn this off.');
67
+ await watchTeamsConnection({ readStatus: () => client.get(`${root}/settings`),
68
+ reconnect: teamsConnector(client, flags, appDirectory, user, objectId, true, signal, log), signal, onProgress: log });
69
+ }
32
70
  export async function runTeamsCommand(client, args, flags, appDirectory) {
33
71
  // Browser credentials may be uploaded only over HTTPS, with HTTP reserved for loopback development.
34
72
  assertCredentialDestination(client.baseUrl);
@@ -38,21 +76,8 @@ export async function runTeamsCommand(client, args, flags, appDirectory) {
38
76
  const resolvedObjectId = resolveTeamsObjectId(user);
39
77
  if (!resolvedObjectId)
40
78
  throw new Error(UNLINKED_ACCOUNT_MESSAGE);
41
- const objectId = resolvedObjectId;
42
- const key = createHash('sha256').update(client.baseUrl + '\0' + user.id).digest('hex').slice(0, 24);
43
- const profileDirectory = flags['browser-profile'] ? resolve(flags['browser-profile']) : join(appDirectory, 'teams', key, 'browser-profile');
44
79
  const abort = new AbortController();
45
- async function connect(connectionId) {
46
- const session = await captureTeamsLogin({ profileDirectory, objectId, browser: flags.browser,
47
- headless: action === 'watch' ? flags.headless !== 'false' : flags.headless === 'true', recover: action === 'watch',
48
- signal: abort.signal, timeoutMs: action === 'watch' ? 90_000 : undefined, onProgress: console.log });
49
- const headers = { 'Content-Type': 'application/json' };
50
- if (connectionId)
51
- headers['If-Match'] = `"${connectionId}"`;
52
- const response = await client.request(`${root}/session`, { method: 'PUT', headers, body: JSON.stringify(session), signal: abort.signal });
53
- await expectOk(response);
54
- await client.post(`${root}/test`);
55
- }
80
+ const connect = teamsConnector(client, flags, appDirectory, user, resolvedObjectId, action === 'watch', abort.signal, console.log);
56
81
  if (action === 'login') {
57
82
  await connect();
58
83
  console.log(`Teams connected. Skillshare refreshes tokens automatically. Use skillshare teams watch for renewed browser SSO after session expiry.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quadient/skillshare",
3
- "version": "1.10.0",
3
+ "version": "1.10.2",
4
4
  "description": "Skillshare CLI for installing and publishing Quadient skills",
5
5
  "type": "module",
6
6
  "bin": {