@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 +18 -1
- package/dist/agents-claude.js +20 -5
- package/dist/agents-codex.js +9 -2
- package/dist/agents-service.js +288 -0
- package/dist/agents-session.js +2 -0
- package/dist/agents-update.js +254 -0
- package/dist/agents.js +187 -15
- package/dist/help.js +7 -5
- package/dist/index.js +13 -3
- package/package.json +2 -2
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`.
|
package/dist/agents-claude.js
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
171
|
+
write(message, steered) {
|
|
157
172
|
const sent = this.process?.write(JSON.stringify({
|
|
158
|
-
type: 'user', message: { role: 'user', content:
|
|
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) {
|
package/dist/agents-codex.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
+
}
|
package/dist/agents-session.js
CHANGED
|
@@ -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
|
|
62
|
-
const max =
|
|
63
|
-
|
|
64
|
-
|
|
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.
|
|
72
|
-
process.
|
|
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({
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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"
|