@quadient/skillshare 1.10.1 → 1.10.3

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
@@ -38,6 +40,21 @@ skillshare agents listen --workdir ~/Research
38
40
  skillshare agents listen --name desktop --max-concurrency 2
39
41
  ```
40
42
 
43
+ The listener updates itself: when Skillshare releases a newer CLI, it downloads it into
44
+ `~/.config/skillshare/versions` (on Windows `%APPDATA%\skillshare\versions`) and restarts on
45
+ it once no turn is running. `--no-update` turns that off.
46
+
47
+ To keep it running without a terminal, install it as a background service that starts at
48
+ every login: a launchd agent on macOS, a systemd user service on Linux and a scheduled task
49
+ on Windows. It takes the same options as `listen`; run it again to change them.
50
+
51
+ ```sh
52
+ skillshare agents service install --workdir ~/Research
53
+ skillshare agents service status
54
+ skillshare agents service logs --lines 100
55
+ skillshare agents service uninstall
56
+ ```
57
+
41
58
  `--sandbox` keeps every agent read-only. Claude Code may only read files, search the web
42
59
  and use the Skillshare MCP; Codex runs in its read-only sandbox. A profile can do the same
43
60
  with `"sandbox": true`.
@@ -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 {
@@ -0,0 +1,288 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { describeOtherListener, installVersion, readListenerLock, runCommand, } from './agents-update.js';
6
+ export const LAUNCHD_LABEL = 'com.quadient.skillshare.agents';
7
+ export const SYSTEMD_UNIT = 'skillshare-agents.service';
8
+ export const WINDOWS_TASK = 'Skillshare Agents';
9
+ export function defaultServiceEnvironment(appDir) {
10
+ const platform = process.platform;
11
+ if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32')
12
+ throw new Error(`The agents service is not supported on ${platform}.`);
13
+ return {
14
+ appDir, platform, home: homedir(), run: runCommand, uid: process.getuid?.(),
15
+ windowsUser: process.env.USERDOMAIN ? `${process.env.USERDOMAIN}\\${process.env.USERNAME}` : process.env.USERNAME,
16
+ };
17
+ }
18
+ export function servicePaths(appDir) {
19
+ return {
20
+ config: join(appDir, 'agents-service.json'),
21
+ launcher: join(appDir, 'agents-service.mjs'),
22
+ log: join(appDir, 'logs', 'agents.log'),
23
+ // What the service manager catches before the listener's own log opens, such as a launcher that cannot start.
24
+ output: join(appDir, 'logs', 'agents-service.out'),
25
+ windowsTask: join(appDir, 'agents-service-task.xml'),
26
+ };
27
+ }
28
+ /** Where the service manager keeps the service definition. */
29
+ export function definitionPath(environment) {
30
+ switch (environment.platform) {
31
+ case 'darwin': return join(environment.home, 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`);
32
+ case 'linux': return join(process.env.XDG_CONFIG_HOME ?? join(environment.home, '.config'), 'systemd', 'user', SYSTEMD_UNIT);
33
+ case 'win32': return servicePaths(environment.appDir).windowsTask;
34
+ }
35
+ }
36
+ /**
37
+ * The launcher the service manager starts. It does not depend on any CLI version: it picks the newest installed one, so an
38
+ * update downloaded by the listener also applies after a restart of the computer.
39
+ */
40
+ export function launcherSource() {
41
+ return `// Starts the Skillshare agents listener as a background service. Written by skillshare agents service install.
42
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
43
+ import { dirname, join } from 'node:path'
44
+ import { fileURLToPath, pathToFileURL } from 'node:url'
45
+
46
+ const appDir = dirname(fileURLToPath(import.meta.url))
47
+ const config = JSON.parse(readFileSync(join(appDir, 'agents-service.json'), 'utf8'))
48
+ const versions = join(appDir, 'versions')
49
+ const parts = (version) => version.split('.').map(Number)
50
+ const compare = (a, b) => { const [x, y] = [parts(a), parts(b)]; return x[0] - y[0] || x[1] - y[1] || x[2] - y[2] }
51
+ const installed = existsSync(versions)
52
+ ? readdirSync(versions).filter((name) => /^\\d+\\.\\d+\\.\\d+$/.test(name) && existsSync(join(versions, name, '.installed'))).sort(compare)
53
+ : []
54
+ const version = installed.at(-1)
55
+ if (!version) {
56
+ console.error('No Skillshare CLI is installed for the agents service. Run skillshare agents service install again.')
57
+ process.exit(1)
58
+ }
59
+ const entry = join(versions, version, 'node_modules', '@quadient', 'skillshare', 'dist', 'index.js')
60
+ process.env.PATH = config.path
61
+ process.env.SKILLSHARE_AGENTS_SERVICE = '1'
62
+ process.chdir(appDir)
63
+ process.argv = [process.argv[0], entry, 'agents', 'listen', '--server', config.server, '--workdir', config.workdir,
64
+ '--log-file', join(appDir, 'logs', 'agents.log'), ...config.args]
65
+ await import(pathToFileURL(entry).href)
66
+ `;
67
+ }
68
+ function xml(value) {
69
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
70
+ }
71
+ /** A launchd agent in the user's login session, so agents reach the keychain and the browser; started again if it fails. */
72
+ export function launchdPlist(config, appDir) {
73
+ const paths = servicePaths(appDir);
74
+ return `<?xml version="1.0" encoding="UTF-8"?>
75
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
76
+ <plist version="1.0">
77
+ <dict>
78
+ <key>Label</key>
79
+ <string>${LAUNCHD_LABEL}</string>
80
+ <key>ProgramArguments</key>
81
+ <array>
82
+ <string>${xml(config.node)}</string>
83
+ <string>${xml(paths.launcher)}</string>
84
+ </array>
85
+ <key>WorkingDirectory</key>
86
+ <string>${xml(appDir)}</string>
87
+ <key>RunAtLoad</key>
88
+ <true/>
89
+ <key>KeepAlive</key>
90
+ <dict>
91
+ <key>SuccessfulExit</key>
92
+ <false/>
93
+ </dict>
94
+ <key>ThrottleInterval</key>
95
+ <integer>30</integer>
96
+ <key>ProcessType</key>
97
+ <string>Interactive</string>
98
+ <key>StandardOutPath</key>
99
+ <string>${xml(paths.output)}</string>
100
+ <key>StandardErrorPath</key>
101
+ <string>${xml(paths.output)}</string>
102
+ </dict>
103
+ </plist>
104
+ `;
105
+ }
106
+ function systemdQuote(value) {
107
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%')}"`;
108
+ }
109
+ /** A systemd user service, started with the user's session and again whenever it fails. */
110
+ export function systemdUnit(config, appDir) {
111
+ const paths = servicePaths(appDir);
112
+ return `[Unit]
113
+ Description=Skillshare agents listener
114
+ After=network-online.target
115
+
116
+ [Service]
117
+ Type=simple
118
+ ExecStart=${systemdQuote(config.node)} ${systemdQuote(paths.launcher)}
119
+ WorkingDirectory=${systemdQuote(appDir)}
120
+ Restart=on-failure
121
+ RestartSec=30
122
+ StandardOutput=append:${paths.output}
123
+ StandardError=append:${paths.output}
124
+
125
+ [Install]
126
+ WantedBy=default.target
127
+ `;
128
+ }
129
+ /**
130
+ * A scheduled task that starts at logon in the user's session, without a time limit and also on battery. conhost
131
+ * --headless runs Node without a console window.
132
+ */
133
+ export function windowsTaskXml(config, appDir, user) {
134
+ const paths = servicePaths(appDir);
135
+ return `<?xml version="1.0" encoding="UTF-16"?>
136
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
137
+ <RegistrationInfo>
138
+ <Description>Skillshare agents listener</Description>
139
+ </RegistrationInfo>
140
+ <Triggers>
141
+ <LogonTrigger>
142
+ <Enabled>true</Enabled>
143
+ <UserId>${xml(user)}</UserId>
144
+ </LogonTrigger>
145
+ </Triggers>
146
+ <Principals>
147
+ <Principal id="Author">
148
+ <UserId>${xml(user)}</UserId>
149
+ <LogonType>InteractiveToken</LogonType>
150
+ <RunLevel>LeastPrivilege</RunLevel>
151
+ </Principal>
152
+ </Principals>
153
+ <Settings>
154
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
155
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
156
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
157
+ <AllowHardTerminate>true</AllowHardTerminate>
158
+ <StartWhenAvailable>true</StartWhenAvailable>
159
+ <AllowStartOnDemand>true</AllowStartOnDemand>
160
+ <Enabled>true</Enabled>
161
+ <Hidden>false</Hidden>
162
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
163
+ <RestartOnFailure>
164
+ <Interval>PT1M</Interval>
165
+ <Count>999</Count>
166
+ </RestartOnFailure>
167
+ </Settings>
168
+ <Actions Context="Author">
169
+ <Exec>
170
+ <Command>conhost.exe</Command>
171
+ <Arguments>--headless "${xml(config.node)}" "${xml(paths.launcher)}"</Arguments>
172
+ <WorkingDirectory>${xml(appDir)}</WorkingDirectory>
173
+ </Exec>
174
+ </Actions>
175
+ </Task>
176
+ `;
177
+ }
178
+ async function must(environment, command, args, what) {
179
+ const result = await environment.run(command, args);
180
+ if (result.code !== 0)
181
+ throw new Error(`${what} failed: ${(result.stderr || result.stdout).trim() || `exit code ${result.code}`}`);
182
+ return result;
183
+ }
184
+ /**
185
+ * Installs the CLI version for the service, writes the launcher and registers it with the service manager, which starts it
186
+ * now and at every login. Installing again replaces the service, for example with other options.
187
+ */
188
+ export async function installService(environment, settings, options = {}) {
189
+ const other = await readListenerLock(environment.appDir);
190
+ if (other && !other.service)
191
+ throw new Error(describeOtherListener(other));
192
+ await (options.install ?? (version => installVersion(environment.appDir, version)))(settings.version);
193
+ const config = { ...settings, path: process.env.PATH ?? '', node: process.execPath, installedAt: new Date().toISOString() };
194
+ const paths = servicePaths(environment.appDir);
195
+ await mkdir(dirname(paths.log), { recursive: true });
196
+ await writeFile(paths.config, JSON.stringify(config, null, 2));
197
+ await writeFile(paths.launcher, launcherSource());
198
+ const definition = definitionPath(environment);
199
+ await mkdir(dirname(definition), { recursive: true });
200
+ switch (environment.platform) {
201
+ case 'darwin': {
202
+ const domain = `gui/${environment.uid}`;
203
+ await environment.run('launchctl', ['bootout', `${domain}/${LAUNCHD_LABEL}`]);
204
+ await writeFile(definition, launchdPlist(config, environment.appDir));
205
+ await must(environment, 'launchctl', ['bootstrap', domain, definition], 'launchctl bootstrap');
206
+ break;
207
+ }
208
+ case 'linux':
209
+ await writeFile(definition, systemdUnit(config, environment.appDir));
210
+ await must(environment, 'systemctl', ['--user', 'daemon-reload'], 'systemctl daemon-reload');
211
+ await must(environment, 'systemctl', ['--user', 'enable', SYSTEMD_UNIT], 'systemctl enable');
212
+ await must(environment, 'systemctl', ['--user', 'restart', SYSTEMD_UNIT], 'systemctl restart');
213
+ break;
214
+ case 'win32':
215
+ // schtasks reads task definitions as UTF-16.
216
+ await writeFile(definition, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(windowsTaskXml(config, environment.appDir, environment.windowsUser ?? ''), 'utf16le')]));
217
+ await environment.run('schtasks', ['/End', '/TN', WINDOWS_TASK]);
218
+ await must(environment, 'schtasks', ['/Create', '/TN', WINDOWS_TASK, '/XML', definition, '/F'], 'schtasks /Create');
219
+ await must(environment, 'schtasks', ['/Run', '/TN', WINDOWS_TASK], 'schtasks /Run');
220
+ break;
221
+ }
222
+ return config;
223
+ }
224
+ /** Stops the service and removes it; downloaded CLI versions and logs stay. */
225
+ export async function uninstallService(environment) {
226
+ const definition = definitionPath(environment);
227
+ switch (environment.platform) {
228
+ case 'darwin':
229
+ await environment.run('launchctl', ['bootout', `gui/${environment.uid}/${LAUNCHD_LABEL}`]);
230
+ break;
231
+ case 'linux':
232
+ await environment.run('systemctl', ['--user', 'disable', '--now', SYSTEMD_UNIT]);
233
+ break;
234
+ case 'win32':
235
+ await environment.run('schtasks', ['/End', '/TN', WINDOWS_TASK]);
236
+ await environment.run('schtasks', ['/Delete', '/TN', WINDOWS_TASK, '/F']);
237
+ break;
238
+ }
239
+ const existed = existsSync(definition) || existsSync(servicePaths(environment.appDir).config);
240
+ const paths = servicePaths(environment.appDir);
241
+ for (const path of [definition, paths.config, paths.launcher])
242
+ await rm(path, { force: true });
243
+ if (environment.platform === 'linux')
244
+ await environment.run('systemctl', ['--user', 'daemon-reload']);
245
+ return existed;
246
+ }
247
+ export async function serviceStatus(environment) {
248
+ let config;
249
+ try {
250
+ config = JSON.parse(await readFile(servicePaths(environment.appDir).config, 'utf8'));
251
+ }
252
+ catch { }
253
+ const lock = await readListenerLock(environment.appDir);
254
+ const running = lock?.service === true;
255
+ return {
256
+ installed: config !== undefined && existsSync(definitionPath(environment)), config, running,
257
+ pid: running ? lock.pid : undefined, version: running ? lock.version : undefined, since: running ? lock.startedAt : undefined,
258
+ };
259
+ }
260
+ export function renderServiceStatus(status, appDir) {
261
+ if (!status.installed)
262
+ return 'The agents service is not installed. Install it with skillshare agents service install.';
263
+ const lines = [
264
+ status.running
265
+ ? `The agents service is running (process ${status.pid}, Skillshare CLI ${status.version}, since ${status.since}).`
266
+ : 'The agents service is installed but not running. See skillshare agents service logs.',
267
+ `Server: ${status.config.server}`,
268
+ `Working directory: ${status.config.workdir}`,
269
+ ];
270
+ if (status.config.args.length)
271
+ lines.push(`Options: ${status.config.args.join(' ')}`);
272
+ lines.push(`Log: ${servicePaths(appDir).log}`);
273
+ return lines.join('\n');
274
+ }
275
+ /** The end of the listener's log, and of the service manager's output when the listener could not start. */
276
+ export async function serviceLogs(appDir, lines) {
277
+ const paths = servicePaths(appDir);
278
+ const tail = async (path) => {
279
+ try {
280
+ return (await readFile(path, 'utf8')).trimEnd().split('\n').slice(-lines).join('\n');
281
+ }
282
+ catch {
283
+ return '';
284
+ }
285
+ };
286
+ const [log, output] = [await tail(paths.log), await tail(paths.output)];
287
+ return [log, output ? `--- ${paths.output}\n${output}` : ''].filter(Boolean).join('\n') || 'The agents service has not written a log yet.';
288
+ }
@@ -135,6 +135,8 @@ export class SessionRunner {
135
135
  });
136
136
  }
137
137
  get stopping() { return this.closing !== null; }
138
+ /** A turn runs or a message waits for one; restarting the listener now would cut it off. */
139
+ get busy() { return this.turnRunning || this.steering.size > 0 || this.pending.length > 0; }
138
140
  /**
139
141
  * Queues messages for the next turns, or steers them into the running turn; the server may send a message again, so
140
142
  * repeats are ignored.
@@ -0,0 +1,254 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
3
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ export const PACKAGE_NAME = '@quadient/skillshare';
7
+ /** A listener exits with this code to be started again on the newest installed CLI. */
8
+ export const RESTART_FOR_UPDATE = 75;
9
+ /** Set for the listener the supervisor runs, so it listens instead of supervising again. */
10
+ export const WORKER_ENV = 'SKILLSHARE_AGENTS_WORKER';
11
+ /** Set when the listener runs as a background service, where nobody can answer a browser sign-in. */
12
+ export const SERVICE_ENV = 'SKILLSHARE_AGENTS_SERVICE';
13
+ const INSTALLED_MARKER = '.installed';
14
+ const MAX_LOG_BYTES = 5 * 1024 * 1024;
15
+ // A listener that fails this soon after its first start has a problem a restart will not fix, such as a bad option.
16
+ const EARLY_FAILURE_MS = 30_000;
17
+ /** Compares x.y.z versions numerically; anything after - or + is ignored. */
18
+ export function compareVersions(a, b) {
19
+ const parse = (version) => version.split(/[-+]/)[0].split('.').map(part => Number(part) || 0);
20
+ const [left, right] = [parse(a), parse(b)];
21
+ for (let index = 0; index < Math.max(left.length, right.length); index++) {
22
+ const difference = (left[index] ?? 0) - (right[index] ?? 0);
23
+ if (difference !== 0)
24
+ return Math.sign(difference);
25
+ }
26
+ return 0;
27
+ }
28
+ export function versionsDirectory(appDir) {
29
+ return join(appDir, 'versions');
30
+ }
31
+ /** The CLI entry point of an installed version. */
32
+ export function versionEntry(appDir, version) {
33
+ return join(versionsDirectory(appDir), version, 'node_modules', '@quadient', 'skillshare', 'dist', 'index.js');
34
+ }
35
+ /** Completely installed versions, oldest first. */
36
+ export async function installedVersions(appDir) {
37
+ let names = [];
38
+ try {
39
+ names = await readdir(versionsDirectory(appDir));
40
+ }
41
+ catch {
42
+ return [];
43
+ }
44
+ return names.filter(name => /^\d+\.\d+\.\d+$/.test(name) && existsSync(join(versionsDirectory(appDir), name, INSTALLED_MARKER)))
45
+ .sort(compareVersions);
46
+ }
47
+ /** Runs a command without a shell, except for Windows .cmd scripts, which Node only starts through one. */
48
+ export const runCommand = (command, args, options = {}) => new Promise((resolve, reject) => {
49
+ const shell = process.platform === 'win32' && /\.cmd$/i.test(command);
50
+ const child = spawn(shell ? quoteForCmd(command) : command, shell ? args.map(quoteForCmd) : args, { cwd: options.cwd, shell, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
51
+ let stdout = '';
52
+ let stderr = '';
53
+ child.stdout.on('data', chunk => { stdout += chunk; });
54
+ child.stderr.on('data', chunk => { stderr += chunk; });
55
+ child.on('error', reject);
56
+ child.on('close', code => resolve({ code: code ?? 1, stdout, stderr }));
57
+ });
58
+ function quoteForCmd(value) {
59
+ return /[\s"&|<>^]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
60
+ }
61
+ /** npm next to the running Node, so a service with a bare PATH still finds it; otherwise npm on PATH. */
62
+ export function npmCommand() {
63
+ const bin = dirname(process.execPath);
64
+ for (const cli of [join(bin, 'node_modules', 'npm', 'bin', 'npm-cli.js'), join(bin, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')])
65
+ if (existsSync(cli))
66
+ return { command: process.execPath, args: [cli] };
67
+ return { command: process.platform === 'win32' ? 'npm.cmd' : 'npm', args: [] };
68
+ }
69
+ /**
70
+ * Installs a CLI version into its own directory under versions, without touching a global install, so no administrator
71
+ * rights are needed. It lands under a temporary name first, so a listener never starts a half-installed version.
72
+ */
73
+ export async function installVersion(appDir, version, options = {}) {
74
+ if ((await installedVersions(appDir)).includes(version))
75
+ return;
76
+ const target = join(versionsDirectory(appDir), version);
77
+ const staging = `${target}.partial-${process.pid}`;
78
+ await rm(staging, { recursive: true, force: true });
79
+ await mkdir(staging, { recursive: true });
80
+ try {
81
+ // An empty project of its own keeps npm from installing into a parent directory's project.
82
+ await writeFile(join(staging, 'package.json'), JSON.stringify({ private: true }));
83
+ const npm = npmCommand();
84
+ const result = await (options.run ?? runCommand)(npm.command, [...npm.args, 'install', '--prefix', staging, '--no-audit', '--no-fund',
85
+ '--omit=dev', '--no-package-lock', options.spec ?? `${PACKAGE_NAME}@${version}`], { cwd: staging });
86
+ if (result.code !== 0)
87
+ throw new Error(`npm could not install ${PACKAGE_NAME}@${version}: ${lastLine(result.stderr) || `exit code ${result.code}`}`);
88
+ const installed = join(staging, 'node_modules', '@quadient', 'skillshare');
89
+ const actual = JSON.parse(await readFile(join(installed, 'package.json'), 'utf8'));
90
+ if (actual.version !== version)
91
+ throw new Error(`npm installed ${PACKAGE_NAME}@${actual.version ?? '?'} instead of ${version}.`);
92
+ await writeFile(join(staging, INSTALLED_MARKER), version);
93
+ await rm(target, { recursive: true, force: true });
94
+ await rename(staging, target);
95
+ }
96
+ catch (error) {
97
+ await rm(staging, { recursive: true, force: true }).catch(() => { });
98
+ throw error;
99
+ }
100
+ }
101
+ function lastLine(text) {
102
+ return text.trim().split('\n').filter(line => line.trim()).at(-1)?.trim() ?? '';
103
+ }
104
+ /** Removes installed versions other than the ones kept, and leftovers of interrupted installs. */
105
+ export async function removeOtherVersions(appDir, keep) {
106
+ let names = [];
107
+ try {
108
+ names = await readdir(versionsDirectory(appDir));
109
+ }
110
+ catch {
111
+ return;
112
+ }
113
+ for (const name of names)
114
+ if (!keep.includes(name))
115
+ await rm(join(versionsDirectory(appDir), name), { recursive: true, force: true }).catch(() => { });
116
+ }
117
+ /** Appends timestamped lines to a log file, starting a new one when it grows past a few megabytes. */
118
+ export function fileLog(path) {
119
+ mkdirSync(dirname(path), { recursive: true });
120
+ return (text) => {
121
+ try {
122
+ if (existsSync(path) && statSync(path).size > MAX_LOG_BYTES)
123
+ renameSync(path, `${path}.1`);
124
+ const stamp = new Date().toISOString();
125
+ appendFileSync(path, text.split(/\r?\n/).filter(line => line.length > 0).map(line => `${stamp} ${line}\n`).join(''));
126
+ }
127
+ catch {
128
+ // Logging must never stop the listener.
129
+ }
130
+ };
131
+ }
132
+ /** Where a supervisor that holds the listener records itself, so a second one does not poll as the same listener. */
133
+ export function listenerLockPath(appDir) {
134
+ return join(appDir, 'agents-listener.json');
135
+ }
136
+ export async function readListenerLock(appDir) {
137
+ try {
138
+ const lock = JSON.parse(await readFile(listenerLockPath(appDir), 'utf8'));
139
+ return isAlive(lock.pid) ? lock : null;
140
+ }
141
+ catch {
142
+ return null;
143
+ }
144
+ }
145
+ /** Records this process as the one listening; returns the other listener instead when one is already running. */
146
+ export async function acquireListenerLock(appDir, lock) {
147
+ const existing = await readListenerLock(appDir);
148
+ if (existing && existing.pid !== process.pid)
149
+ return existing;
150
+ await mkdir(appDir, { recursive: true });
151
+ await writeFile(listenerLockPath(appDir), JSON.stringify({ ...lock, pid: process.pid, startedAt: new Date().toISOString() }, null, 2));
152
+ return null;
153
+ }
154
+ export async function releaseListenerLock(appDir) {
155
+ const lock = await readListenerLock(appDir);
156
+ if (lock?.pid === process.pid)
157
+ await rm(listenerLockPath(appDir), { force: true }).catch(() => { });
158
+ }
159
+ export function isAlive(pid) {
160
+ try {
161
+ process.kill(pid, 0);
162
+ return true;
163
+ }
164
+ catch (error) {
165
+ // EPERM: the process exists but belongs to someone else.
166
+ return error.code === 'EPERM';
167
+ }
168
+ }
169
+ /**
170
+ * Keeps a listener running on the newest installed CLI. The listener downloads a new version the server announces and
171
+ * exits with RESTART_FOR_UPDATE once no turn is running; the supervisor then starts the new version. A listener that
172
+ * crashes later is restarted with a growing pause; one that fails right after its first start ends the supervisor with its
173
+ * exit code, because the problem is in how it was started.
174
+ */
175
+ export async function superviseListener(options) {
176
+ const write = options.logFile ? fileLog(options.logFile) : undefined;
177
+ const log = options.log ?? ((message) => write ? write(message) : console.log(message));
178
+ const existing = await acquireListenerLock(options.appDir, { version: options.ownVersion, service: options.service === true });
179
+ if (existing) {
180
+ log(describeOtherListener(existing));
181
+ return 1;
182
+ }
183
+ let child = null;
184
+ let stopping = false;
185
+ const stop = () => {
186
+ if (stopping)
187
+ return;
188
+ stopping = true;
189
+ // In a terminal, Ctrl+C reaches the listener directly; elsewhere it is told here. Windows cannot signal gracefully.
190
+ if (child && process.platform !== 'win32')
191
+ child.kill('SIGTERM');
192
+ if (child)
193
+ setTimeout(() => child?.kill('SIGKILL'), 30_000).unref();
194
+ };
195
+ process.on('SIGINT', stop);
196
+ process.on('SIGTERM', stop);
197
+ options.signal?.addEventListener('abort', stop, { once: true });
198
+ let failures = 0;
199
+ let first = true;
200
+ try {
201
+ while (!stopping) {
202
+ const newest = (await installedVersions(options.appDir)).at(-1);
203
+ const useInstalled = newest !== undefined && compareVersions(newest, options.ownVersion) > 0;
204
+ const version = useInstalled ? newest : options.ownVersion;
205
+ const entry = useInstalled ? [versionEntry(options.appDir, newest)] : options.ownEntry ?? [...process.execArgv, process.argv[1]];
206
+ // Older versions are not needed any more; this CLI's own copy stays while it runs.
207
+ await removeOtherVersions(options.appDir, [version, options.ownVersion]);
208
+ log(`Starting the listener with Skillshare CLI ${version}.`);
209
+ await acquireListenerLock(options.appDir, { version, service: options.service === true });
210
+ const startedAt = Date.now();
211
+ child = spawn(process.execPath, [...entry, ...options.args], {
212
+ env: { ...process.env, [WORKER_ENV]: '1', ...(options.service ? { [SERVICE_ENV]: '1' } : {}) },
213
+ stdio: write ? ['ignore', 'pipe', 'pipe'] : 'inherit',
214
+ windowsHide: true,
215
+ });
216
+ if (write) {
217
+ child.stdout?.on('data', chunk => write(String(chunk)));
218
+ child.stderr?.on('data', chunk => write(String(chunk)));
219
+ }
220
+ const code = await new Promise(resolve => {
221
+ child.on('error', () => resolve(1));
222
+ child.on('exit', (exitCode, signal) => resolve(exitCode ?? (signal ? 128 : 1)));
223
+ });
224
+ child = null;
225
+ if (stopping)
226
+ return 0;
227
+ if (code === RESTART_FOR_UPDATE) {
228
+ failures = 0;
229
+ first = false;
230
+ continue;
231
+ }
232
+ if (code === 0)
233
+ return 0;
234
+ if (first && Date.now() - startedAt < EARLY_FAILURE_MS)
235
+ return code;
236
+ first = false;
237
+ failures = Date.now() - startedAt > 10 * 60_000 ? 1 : failures + 1;
238
+ const pause = Math.min(5 * 60_000, 5000 * 2 ** (failures - 1));
239
+ log(`The listener stopped with exit code ${code}; starting it again in ${Math.round(pause / 1000)} s.`);
240
+ await delay(pause, undefined, { signal: options.signal }).catch(() => { });
241
+ }
242
+ return 0;
243
+ }
244
+ finally {
245
+ process.removeListener('SIGINT', stop);
246
+ process.removeListener('SIGTERM', stop);
247
+ await releaseListenerLock(options.appDir);
248
+ }
249
+ }
250
+ export function describeOtherListener(lock) {
251
+ return lock.service
252
+ ? `The Skillshare agents service already listens on this computer (process ${lock.pid}). Stop it with skillshare agents service uninstall, or leave it running.`
253
+ : `Another skillshare agents listen already runs on this computer (process ${lock.pid}). Stop it first; both would answer as the same listener.`;
254
+ }
package/dist/agents.js CHANGED
@@ -1,12 +1,14 @@
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
+ import { defaultServiceEnvironment, installService, renderServiceStatus, serviceLogs, serviceStatus, uninstallService, } from './agents-service.js';
7
8
  import { createCodexDriver } from './agents-codex.js';
8
9
  import { detectAgents, profilesPath } from './agents-detect.js';
9
- import { SessionRunner } from './agents-session.js';
10
+ import { SessionRunner, errorMessage, } from './agents-session.js';
11
+ import { RESTART_FOR_UPDATE, SERVICE_ENV, WORKER_ENV, acquireListenerLock, compareVersions, describeOtherListener, installVersion, releaseListenerLock, superviseListener, } from './agents-update.js';
10
12
  import { readCliVersion } from './help.js';
11
13
  const root = '/api/agents';
12
14
  const PROFILE_HINT = 'Declare named profiles in ' + profilesPath() + ' to set a working directory or extra arguments.';
@@ -29,6 +31,41 @@ async function send(client, method, path, body, signal) {
29
31
  }
30
32
  return response.status === 204 ? undefined : await response.json();
31
33
  }
34
+ const MAX_IMAGE_BYTES = 6 * 1024 * 1024;
35
+ const IMAGE_ID = /^[0-9a-f]{32}\.(png|jpg|gif|webp)$/;
36
+ /**
37
+ * Downloads the images of each message into the session's directory, so the agent reads them as local files. An image that
38
+ * cannot be fetched is left out and the agent is told, so the turn still runs.
39
+ */
40
+ export async function downloadImages(client, directory, messages) {
41
+ return Promise.all(messages.map(async (message) => {
42
+ if (!message.images?.length)
43
+ return message;
44
+ const images = [];
45
+ const problems = [];
46
+ for (const image of message.images) {
47
+ try {
48
+ if (!IMAGE_ID.test(image.id) || !image.url.startsWith(`${root}/sessions/`))
49
+ throw new Error('unexpected image link');
50
+ const response = await client.request(image.url, { method: 'GET' });
51
+ if (!response.ok)
52
+ throw new Error(`HTTP ${response.status}`);
53
+ const data = Buffer.from(await response.arrayBuffer());
54
+ if (data.length > MAX_IMAGE_BYTES)
55
+ throw new Error('the image is too large');
56
+ await mkdir(directory, { recursive: true });
57
+ const path = join(directory, image.id);
58
+ await writeFile(path, data);
59
+ images.push({ ...image, path });
60
+ }
61
+ catch (error) {
62
+ problems.push(errorMessage(error));
63
+ }
64
+ }
65
+ const note = problems.length ? `\n\n[${problems.length} attached image(s) could not be downloaded from Skillshare: ${problems.join('; ')}]` : '';
66
+ return { ...message, text: (message.text + note).trim(), images };
67
+ }));
68
+ }
32
69
  /** One stable listener id per Skillshare server and account, so the web keeps routes across restarts. */
33
70
  export async function loadListenerId(appDirectory, baseUrl, userId) {
34
71
  const key = createHash('sha256').update(baseUrl + '\0' + userId).digest('hex').slice(0, 24);
@@ -57,19 +94,34 @@ export async function runAgentsCommand(client, args, flags, appDirectory, teamsW
57
94
  console.log(renderDetected(listenerId, client.baseUrl, detected));
58
95
  return;
59
96
  }
97
+ if (action === 'service')
98
+ return runServiceCommand(args[1], flags, appDirectory, client.baseUrl);
60
99
  if (action !== 'listen')
61
- throw new Error('agents requires listen or status.');
62
- const max = flags['max-concurrency'] === undefined ? undefined : Number(flags['max-concurrency']);
63
- if (max !== undefined && (!Number.isInteger(max) || max < 1 || max > 4))
64
- throw new Error('--max-concurrency must be between 1 and 4.');
100
+ throw new Error('agents requires listen, status or service.');
101
+ const max = maxConcurrency(flags);
102
+ const worker = process.env[WORKER_ENV] === '1';
103
+ const service = process.env[SERVICE_ENV] === '1';
104
+ // The listener runs under a supervisor that starts it again on a newer CLI, unless updates are turned off.
105
+ if (!worker && flags['no-update'] !== 'true') {
106
+ const code = await superviseListener({ appDir: appDirectory, args: process.argv.slice(2), ownVersion: readCliVersion(), logFile: flags['log-file'], service });
107
+ if (code !== 0)
108
+ process.exitCode = code;
109
+ return;
110
+ }
111
+ if (!worker) {
112
+ const other = await acquireListenerLock(appDirectory, { version: readCliVersion(), service });
113
+ if (other)
114
+ throw new Error(describeOtherListener(other));
115
+ }
65
116
  const workdir = resolve(flags.workdir ?? process.cwd());
66
117
  const sandbox = flags.sandbox === 'true';
67
118
  const user = await client.get('/api/auth/me');
68
119
  const listenerId = await loadListenerId(appDirectory, client.baseUrl, user.id);
69
120
  const abort = new AbortController();
121
+ // A supervisor may pass on a signal the listener already got from the terminal; stopping twice is harmless.
70
122
  const stop = () => abort.abort();
71
- process.once('SIGINT', stop);
72
- process.once('SIGTERM', stop);
123
+ process.on('SIGINT', stop);
124
+ process.on('SIGTERM', stop);
73
125
  console.log(sandbox
74
126
  ? 'Sandboxed: agents may read files, search and use the Skillshare MCP tools, but cannot change files or run commands that modify anything.'
75
127
  : 'Agents run with your full local permissions and configuration, without permission prompts. Use --sandbox to keep them read-only.');
@@ -79,14 +131,82 @@ export async function runAgentsCommand(client, args, flags, appDirectory, teamsW
79
131
  ? teamsWatch(abort.signal, message => console.log(`[teams] ${message}`))
80
132
  .catch(error => console.log(`[teams] Teams renewal stopped: ${error instanceof Error ? error.message : String(error)}`))
81
133
  : Promise.resolve();
134
+ let result = 'stopped';
82
135
  try {
83
- await listen({ client, listenerId, name: flags.name, workdir, maxConcurrency: max, sandbox, signal: abort.signal, onProgress: console.log });
136
+ result = await listen({
137
+ client, listenerId, name: flags.name, workdir, maxConcurrency: max, sandbox, signal: abort.signal, onProgress: console.log, service,
138
+ updater: worker ? { currentVersion: readCliVersion(), prepare: version => installVersion(appDirectory, version) } : undefined,
139
+ });
84
140
  }
85
141
  finally {
86
142
  abort.abort();
87
143
  await teams;
88
144
  process.removeListener('SIGINT', stop);
89
145
  process.removeListener('SIGTERM', stop);
146
+ if (!worker)
147
+ await releaseListenerLock(appDirectory);
148
+ }
149
+ if (result === 'update') {
150
+ process.exitCode = RESTART_FOR_UPDATE;
151
+ // Nothing may keep the old version alive; the supervisor waits for this process to end.
152
+ setTimeout(() => process.exit(RESTART_FOR_UPDATE), 5000).unref();
153
+ }
154
+ }
155
+ function maxConcurrency(flags) {
156
+ const max = flags['max-concurrency'] === undefined ? undefined : Number(flags['max-concurrency']);
157
+ if (max !== undefined && (!Number.isInteger(max) || max < 1 || max > 4))
158
+ throw new Error('--max-concurrency must be between 1 and 4.');
159
+ return max;
160
+ }
161
+ /** Listen options a service keeps; the server and the working directory are stored on their own. */
162
+ export function serviceListenArgs(flags) {
163
+ const args = [];
164
+ for (const key of ['name', 'max-concurrency', 'browser', 'browser-profile'])
165
+ if (flags[key] !== undefined)
166
+ args.push(`--${key}`, flags[key]);
167
+ for (const key of ['sandbox', 'no-teams-watch', 'headless'])
168
+ if (flags[key] === 'true')
169
+ args.push(`--${key}`);
170
+ return args;
171
+ }
172
+ /** install needs the server, so it runs after sign-in; the other actions work offline. */
173
+ export async function runServiceCommand(action, flags, appDirectory, server) {
174
+ const environment = defaultServiceEnvironment(appDirectory);
175
+ switch (action) {
176
+ case 'install': {
177
+ if (!server)
178
+ throw new Error('agents service install needs a Skillshare server.');
179
+ maxConcurrency(flags);
180
+ const workdir = resolve(flags.workdir ?? process.cwd());
181
+ const version = readCliVersion();
182
+ console.log(`Installing Skillshare CLI ${version} for the agents service…`);
183
+ await installService(environment, { server, workdir, args: serviceListenArgs(flags), version });
184
+ // The service manager starts it right away; it takes a moment to sign in and report.
185
+ let status = await serviceStatus(environment);
186
+ for (let attempt = 0; attempt < 30 && !status.running; attempt++) {
187
+ await delay(500);
188
+ status = await serviceStatus(environment);
189
+ }
190
+ console.log(renderServiceStatus(status, appDirectory));
191
+ console.log('It starts at every login and updates itself when Skillshare releases a new CLI.' +
192
+ (environment.platform === 'linux' ? ' To keep it running after you log out, run loginctl enable-linger.' : ''));
193
+ return;
194
+ }
195
+ case 'uninstall':
196
+ console.log(await uninstallService(environment) ? 'The agents service was removed.' : 'The agents service was not installed.');
197
+ return;
198
+ case 'status':
199
+ console.log(renderServiceStatus(await serviceStatus(environment), appDirectory));
200
+ return;
201
+ case 'logs': {
202
+ const lines = flags.lines === undefined ? 50 : Number(flags.lines);
203
+ if (!Number.isInteger(lines) || lines < 1)
204
+ throw new Error('--lines must be a positive number.');
205
+ console.log(await serviceLogs(appDirectory, lines));
206
+ return;
207
+ }
208
+ default:
209
+ throw new Error('agents service requires install, uninstall, status or logs.');
90
210
  }
91
211
  }
92
212
  export function renderDetected(listenerId, server, detected) {
@@ -117,16 +237,47 @@ export async function listen(options) {
117
237
  const now = options.now ?? Date.now;
118
238
  const log = options.onProgress ?? (() => { });
119
239
  const sessions = new Map();
240
+ // Messages reach a session in order even while earlier ones still download their images.
241
+ const deliveries = new Map();
242
+ const imageRoot = options.imageDirectory ?? join(tmpdir(), 'skillshare-agents');
120
243
  let detected = await detect();
121
244
  let registeredFingerprint = '';
122
245
  let detectedAt = now();
123
246
  let failures = 0;
247
+ // A downloaded update waits until no turn runs; a failed download is tried again later.
248
+ let updateReady = null;
249
+ let updating = null;
250
+ let updateRetryAt = 0;
251
+ let updateMentioned = '';
252
+ let result = 'stopped';
253
+ function considerUpdate(version) {
254
+ const current = options.updater?.currentVersion ?? readCliVersion();
255
+ if (!version || compareVersions(version, current) <= 0 || updateReady === version || updating === version)
256
+ return;
257
+ if (!options.updater) {
258
+ if (updateMentioned !== version)
259
+ log(`Skillshare CLI ${version} is available. Update with npm install -g @quadient/skillshare@${version}.`);
260
+ updateMentioned = version;
261
+ return;
262
+ }
263
+ if (now() < updateRetryAt)
264
+ return;
265
+ updating = version;
266
+ log(`Downloading Skillshare CLI ${version}…`);
267
+ options.updater.prepare(version).then(() => {
268
+ updateReady = version;
269
+ log(`Skillshare CLI ${version} is ready; the listener restarts with it once no turn is running.`);
270
+ }).catch(error => {
271
+ updateRetryAt = now() + 30 * 60_000;
272
+ log(`! Could not download Skillshare CLI ${version} (${error instanceof Error ? error.message : String(error)}). Trying again in 30 minutes.`);
273
+ }).finally(() => { updating = null; });
274
+ }
124
275
  const capabilities = () => options.sandbox ? { ...detected.capabilities, sandbox: true } : detected.capabilities;
125
276
  async function register() {
126
277
  const fingerprint = JSON.stringify(capabilities());
127
278
  const registered = await send(options.client, 'PUT', `${root}/listeners/${options.listenerId}`, {
128
279
  name: options.name ?? hostname(), host: hostname(), os: platform(), cliVersion: readCliVersion(), maxConcurrency: options.maxConcurrency,
129
- capabilities: capabilities(),
280
+ capabilities: capabilities(), service: options.service === true, autoUpdate: options.updater !== undefined,
130
281
  }, options.signal);
131
282
  registeredFingerprint = fingerprint;
132
283
  for (const problem of detected.problems)
@@ -162,24 +313,43 @@ export async function listen(options) {
162
313
  });
163
314
  sessions.set(command.sessionId, runner);
164
315
  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)
316
+ void runner.done.then(async () => {
317
+ if (sessions.get(command.sessionId) === runner) {
167
318
  sessions.delete(command.sessionId);
319
+ deliveries.delete(command.sessionId);
320
+ await rm(join(imageRoot, command.sessionId), { recursive: true, force: true }).catch(() => { });
321
+ }
168
322
  log(`Closed session ${command.sessionId}.`);
169
323
  });
170
- runner.send(command.messages ?? []);
324
+ deliver(runner, command.messages ?? []);
325
+ }
326
+ function deliver(runner, messages) {
327
+ if (messages.length === 0)
328
+ return;
329
+ if (!messages.some(message => message.images?.length) && !deliveries.has(runner.sessionId))
330
+ return runner.send(messages);
331
+ const previous = deliveries.get(runner.sessionId) ?? Promise.resolve();
332
+ const next = previous.then(() => downloadImages(options.client, join(imageRoot, runner.sessionId), messages)).then(ready => runner.send(ready));
333
+ deliveries.set(runner.sessionId, next);
334
+ void next.finally(() => { if (deliveries.get(runner.sessionId) === next)
335
+ deliveries.delete(runner.sessionId); });
171
336
  }
172
337
  function handle(command) {
173
338
  const runner = sessions.get(command.sessionId);
174
339
  switch (command.type) {
175
340
  case 'start': return start(command);
176
- case 'send': return runner?.send(command.messages ?? []);
341
+ case 'send': return runner && deliver(runner, command.messages ?? []);
177
342
  case 'interrupt': return void runner?.interrupt();
178
343
  case 'close': return void runner?.close();
179
344
  }
180
345
  }
181
346
  try {
182
347
  while (!options.signal.aborted) {
348
+ if (updateReady && ![...sessions.values()].some(runner => runner.busy)) {
349
+ log(`Restarting with Skillshare CLI ${updateReady}.`);
350
+ result = 'update';
351
+ break;
352
+ }
183
353
  try {
184
354
  if (now() - detectedAt > (options.redetectMs ?? 5 * 60_000)) {
185
355
  detected = await detect();
@@ -196,6 +366,7 @@ export async function listen(options) {
196
366
  failures = 0;
197
367
  for (const command of response.commands)
198
368
  handle(command);
369
+ considerUpdate(response.cliVersion);
199
370
  if (response.commands.length === 0 && now() - polledAt < 1000)
200
371
  await wait(1000, options.signal).catch(() => { });
201
372
  }
@@ -222,6 +393,7 @@ export async function listen(options) {
222
393
  // Closing reports each session as closed, so the server knows at once that the next message must resume it.
223
394
  await Promise.all([...sessions.values()].map(runner => runner.close()));
224
395
  }
396
+ return result;
225
397
  }
226
398
  function label(start) {
227
399
  const { configuration } = start;
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', 'no-teams-watch', 'sandbox']);
3
+ export const BOOLEAN_FLAGS = new Set(['dry-run', 'force', 'headless', 'help', 'json', 'no-teams-watch', 'no-update', 'sandbox']);
4
4
  export const COMMANDS = [
5
5
  {
6
6
  name: 'login',
@@ -112,12 +112,14 @@ export const COMMANDS = [
112
112
  name: 'agents',
113
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] [--no-teams-watch]',
115
+ 'agents listen [local] [--name <name>] [--workdir <directory>] [--max-concurrency <1-4>] [--sandbox] [--no-teams-watch] [--no-update]',
116
116
  'agents status [local] [--json]',
117
+ 'agents service install [local] [--name <name>] [--workdir <directory>] [--max-concurrency <1-4>] [--sandbox] [--no-teams-watch]',
118
+ 'agents service status | logs [--lines <n>] | uninstall',
117
119
  ],
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
- examples: ['skillshare agents status', 'skillshare agents listen --workdir ~/Research'],
120
- flags: ['name', 'workdir', 'max-concurrency', 'sandbox', 'no-teams-watch', 'browser', 'headless', 'browser-profile', 'json'],
120
+ 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. The listener updates itself: when Skillshare releases a newer CLI, it downloads it and restarts on it once no turn is running (--no-update turns that off). agents service install runs the listener in the background, started at every login (a launchd agent on macOS, a systemd user service on Linux, a scheduled task on Windows); status, logs and uninstall manage it.',
121
+ examples: ['skillshare agents status', 'skillshare agents listen --workdir ~/Research', 'skillshare agents service install --workdir ~/Research'],
122
+ flags: ['name', 'workdir', 'max-concurrency', 'sandbox', 'no-teams-watch', 'no-update', 'log-file', 'lines', 'browser', 'headless', 'browser-profile', 'json'],
121
123
  },
122
124
  {
123
125
  name: 'git',
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { assertCredentialDestination, resolveTeamsObjectId, runTeamsCommand, watchTeamsInBackground } from './teams.js';
2
- import { runAgentsCommand } from './agents.js';
2
+ import { runAgentsCommand, runServiceCommand } from './agents.js';
3
+ import { SERVICE_ENV } from './agents-update.js';
3
4
  import { formatUnknownCommand, parseCliArgs } from './cli-args.js';
4
5
  import { findCommand, readCliVersion, renderCommandHelp, renderUsage } from './help.js';
5
6
  import { inspectInstallTarget, installFilesAtomically } from './install-files.js';
@@ -145,10 +146,19 @@ async function main() {
145
146
  return;
146
147
  }
147
148
  case 'agents': {
148
- const baseUrl = options.flags.server ?? (options.positionals[1] === 'local' ? LOCAL_URL : await resolveActiveBaseUrl());
149
+ // Managing the background service needs no server, except to install it.
150
+ if (options.positionals[0] === 'service' && options.positionals[1] !== 'install') {
151
+ await runServiceCommand(options.positionals[1], options.flags, APP_DIR);
152
+ return;
153
+ }
154
+ const baseUrl = options.flags.server ?? (options.positionals.slice(1).includes('local') ? LOCAL_URL : await resolveActiveBaseUrl());
149
155
  assertCredentialDestination(baseUrl);
150
- if (!options.token && !await loadSession(baseUrl))
156
+ if (!options.token && !await loadSession(baseUrl)) {
157
+ // A background service has nobody to finish a browser sign-in.
158
+ if (process.env[SERVICE_ENV] === '1')
159
+ throw new Error(`Not signed in to ${baseUrl}. Run skillshare login, then skillshare agents service install again.`);
151
160
  await login(baseUrl);
161
+ }
152
162
  const agentsClient = await createClient(baseUrl, options.token);
153
163
  await runAgentsCommand(agentsClient, options.positionals, options.flags, APP_DIR, (signal, log) => watchTeamsInBackground(agentsClient, options.flags, APP_DIR, signal, log));
154
164
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quadient/skillshare",
3
- "version": "1.10.1",
3
+ "version": "1.10.3",
4
4
  "description": "Skillshare CLI for installing and publishing Quadient skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
17
17
  "start": "node ./dist/index.js",
18
18
  "prepublishOnly": "npm run build",
19
- "test": "tsx --test cli-args.test.ts install-files.test.ts output.test.ts pages.test.ts pages-dev.test.ts teams-auth.test.ts teams-watch.test.ts teams-browser.test.ts agents.test.ts"
19
+ "test": "tsx --test cli-args.test.ts install-files.test.ts output.test.ts pages.test.ts pages-dev.test.ts teams-auth.test.ts teams-watch.test.ts teams-browser.test.ts agents.test.ts agents-update.test.ts"
20
20
  },
21
21
  "publishConfig": {
22
22
  "access": "public"