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