@quadient/skillshare 1.10.0 → 1.10.1
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 +2 -1
- package/dist/agents-claude.js +61 -13
- package/dist/agents-codex.js +18 -2
- package/dist/agents-detect.js +73 -12
- package/dist/agents-session.js +32 -3
- package/dist/agents.js +8 -1
- 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
|
@@ -59,7 +59,8 @@ segment are sent to Skillshare.
|
|
|
59
59
|
Starting with 1.7.0, `skillshare teams login` opens a dedicated local browser profile,
|
|
60
60
|
connects your own Teams account to Skillshare and verifies the connection. Sign in with
|
|
61
61
|
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
|
|
62
|
+
background. `teams watch` renews an expired Microsoft grant through local browser SSO; `agents listen` does the same
|
|
63
|
+
while it runs, so a computer that runs agents needs no separate `teams watch` (`--no-teams-watch` turns it off).
|
|
63
64
|
Keep that process running for automatic recovery beyond the SPA refresh token lifetime;
|
|
64
65
|
another interactive CLI login is needed only when SSO cannot complete, for example MFA.
|
|
65
66
|
|
package/dist/agents-claude.js
CHANGED
|
@@ -13,7 +13,8 @@ export function claudeArgs(context, mcpConfigPath) {
|
|
|
13
13
|
const { start } = context;
|
|
14
14
|
const sandbox = isSandboxed(start.configuration, context);
|
|
15
15
|
const servers = start.mcpServers ?? [];
|
|
16
|
-
|
|
16
|
+
// Replayed user messages tell when Claude Code took a message, which is how a steered message is known to have joined the turn.
|
|
17
|
+
const args = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--include-partial-messages', '--replay-user-messages',
|
|
17
18
|
...(sandbox
|
|
18
19
|
// A sandboxed session never prompts: anything outside the read-only allowlist is denied.
|
|
19
20
|
? ['--permission-mode', 'dontAsk', '--allowedTools', ...CLAUDE_SANDBOX_TOOLS, ...servers.map((server, index) => `mcp__${mcpServerName(server, index)}`)]
|
|
@@ -80,6 +81,12 @@ class ClaudeDriver {
|
|
|
80
81
|
blocks = new Map();
|
|
81
82
|
streamed = new Map();
|
|
82
83
|
messageId = '';
|
|
84
|
+
/** Messages written to Claude Code whose replay has not come back yet, in order. */
|
|
85
|
+
echoes = [];
|
|
86
|
+
/** Steered messages Claude Code will run as their own turn; turn() then only waits for their outcome. */
|
|
87
|
+
submitted = new Set();
|
|
88
|
+
/** Outcomes of such turns that ended before turn() asked for them. */
|
|
89
|
+
early = [];
|
|
83
90
|
costSoFar = 0;
|
|
84
91
|
closing = false;
|
|
85
92
|
constructor(context, options) {
|
|
@@ -114,16 +121,55 @@ class ClaudeDriver {
|
|
|
114
121
|
}
|
|
115
122
|
turn(message) {
|
|
116
123
|
return new Promise((resolve, reject) => {
|
|
124
|
+
if (this.submitted.delete(message.id)) {
|
|
125
|
+
const early = this.early.shift();
|
|
126
|
+
if (early)
|
|
127
|
+
resolve(early);
|
|
128
|
+
else
|
|
129
|
+
this.current = { resolve, interrupted: false };
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
117
132
|
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) {
|
|
133
|
+
if (!this.write(message.text)) {
|
|
122
134
|
this.current = null;
|
|
123
135
|
reject(new Error('Claude Code is not accepting input.'));
|
|
124
136
|
}
|
|
125
137
|
});
|
|
126
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Claude Code takes a message written during a turn at its next tool boundary. If the turn ends first, the message starts
|
|
141
|
+
* a turn of its own; its replay arriving before or after the turn's result tells which happened.
|
|
142
|
+
*/
|
|
143
|
+
steer(message) {
|
|
144
|
+
if (!this.current)
|
|
145
|
+
return Promise.resolve('notSent');
|
|
146
|
+
return new Promise(resolve => {
|
|
147
|
+
if (!this.write(message.text, outcome => {
|
|
148
|
+
if (outcome === 'steered')
|
|
149
|
+
this.submitted.delete(message.id);
|
|
150
|
+
resolve(outcome);
|
|
151
|
+
}))
|
|
152
|
+
return resolve('notSent');
|
|
153
|
+
this.submitted.add(message.id);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
write(text, steered) {
|
|
157
|
+
const sent = this.process?.write(JSON.stringify({
|
|
158
|
+
type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null, session_id: this.sessionId ?? '',
|
|
159
|
+
}));
|
|
160
|
+
if (sent)
|
|
161
|
+
this.echoes.push({ text, steered });
|
|
162
|
+
return Boolean(sent);
|
|
163
|
+
}
|
|
164
|
+
replayed(content) {
|
|
165
|
+
const text = typeof content === 'string' ? content
|
|
166
|
+
: Array.isArray(content) ? content.map((block) => typeof block?.text === 'string' ? block.text : '').join('') : '';
|
|
167
|
+
const index = this.echoes.findIndex(echo => echo.text === text);
|
|
168
|
+
if (index < 0)
|
|
169
|
+
return;
|
|
170
|
+
const [echo] = this.echoes.splice(index, 1);
|
|
171
|
+
echo.steered?.(this.current ? 'steered' : 'nextTurn');
|
|
172
|
+
}
|
|
127
173
|
async interrupt() {
|
|
128
174
|
if (!this.current)
|
|
129
175
|
return;
|
|
@@ -191,7 +237,7 @@ class ClaudeDriver {
|
|
|
191
237
|
case 'assistant':
|
|
192
238
|
return this.assistant(frame.message ?? {});
|
|
193
239
|
case 'user':
|
|
194
|
-
return this.toolResults(frame.message?.content);
|
|
240
|
+
return frame.isReplay === true ? this.replayed(frame.message?.content) : this.toolResults(frame.message?.content);
|
|
195
241
|
case 'result':
|
|
196
242
|
return this.result(frame);
|
|
197
243
|
}
|
|
@@ -242,8 +288,7 @@ class ClaudeDriver {
|
|
|
242
288
|
this.adopt(frame.session_id);
|
|
243
289
|
const current = this.current;
|
|
244
290
|
this.current = null;
|
|
245
|
-
|
|
246
|
-
return;
|
|
291
|
+
const record = (outcome) => current ? current.resolve(outcome) : this.early.push(outcome);
|
|
247
292
|
// Costs are totals for the process; a turn's share is the difference.
|
|
248
293
|
const cost = typeof frame.total_cost_usd === 'number' ? frame.total_cost_usd : undefined;
|
|
249
294
|
const turnCost = cost === undefined ? undefined : Math.max(0, cost - this.costSoFar);
|
|
@@ -251,21 +296,24 @@ class ClaudeDriver {
|
|
|
251
296
|
this.costSoFar = cost;
|
|
252
297
|
const usage = frame.usage ?? {};
|
|
253
298
|
const input = [usage.input_tokens, usage.cache_creation_input_tokens, usage.cache_read_input_tokens].filter((n) => typeof n === 'number');
|
|
299
|
+
const count = (value) => typeof value === 'number' ? value : undefined;
|
|
254
300
|
const stats = { costUsd: turnCost, inputTokens: input.length > 0 ? input.reduce((a, b) => a + b, 0) : undefined,
|
|
255
|
-
|
|
301
|
+
cachedInputTokens: count(usage.cache_read_input_tokens), cacheWriteTokens: count(usage.cache_creation_input_tokens), outputTokens: count(usage.output_tokens) };
|
|
256
302
|
if (frame.subtype === 'success' && frame.is_error !== true)
|
|
257
|
-
return
|
|
258
|
-
if (current
|
|
259
|
-
return
|
|
303
|
+
return record({ status: 'completed', stats });
|
|
304
|
+
if (current?.interrupted)
|
|
305
|
+
return record({ status: 'interrupted', stats });
|
|
260
306
|
const detail = typeof frame.result === 'string' && frame.result ? frame.result
|
|
261
307
|
: Array.isArray(frame.errors) && frame.errors.length > 0 ? frame.errors.join(' ') : `Claude Code ended the turn with ${frame.subtype ?? 'an error'}.`;
|
|
262
|
-
|
|
308
|
+
record({ status: 'failed', error: clip(detail, 2000), stats });
|
|
263
309
|
}
|
|
264
310
|
onExit(code) {
|
|
265
311
|
const reason = `Claude Code exited${code === null ? '' : ` with code ${code}`}${this.process?.stderrTail() ? `: ${this.process.stderrTail()}` : '.'}`;
|
|
266
312
|
for (const waiter of this.controls.values())
|
|
267
313
|
waiter.reject(new Error(reason));
|
|
268
314
|
this.controls.clear();
|
|
315
|
+
for (const echo of this.echoes.splice(0))
|
|
316
|
+
echo.steered?.('notSent');
|
|
269
317
|
this.current?.resolve({ status: 'failed', error: reason });
|
|
270
318
|
this.current = null;
|
|
271
319
|
if (!this.closing)
|
package/dist/agents-codex.js
CHANGED
|
@@ -126,7 +126,7 @@ class CodexDriver {
|
|
|
126
126
|
turn(message) {
|
|
127
127
|
const { configuration } = this.context.start;
|
|
128
128
|
return new Promise((resolve, reject) => {
|
|
129
|
-
this.current = { resolve, usage: { input: 0, output: 0 } };
|
|
129
|
+
this.current = { resolve, usage: { input: 0, cached: 0, cacheWrite: 0, output: 0 } };
|
|
130
130
|
this.request('turn/start', {
|
|
131
131
|
threadId: this.threadId, input: [{ type: 'text', text: message.text }],
|
|
132
132
|
...(configuration.reasoning !== DEFAULT ? { effort: configuration.reasoning } : {}),
|
|
@@ -143,6 +143,19 @@ class CodexDriver {
|
|
|
143
143
|
if (this.current?.id && this.threadId)
|
|
144
144
|
await this.request('turn/interrupt', { threadId: this.threadId, turnId: this.current.id });
|
|
145
145
|
}
|
|
146
|
+
/** 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. */
|
|
147
|
+
async steer(message) {
|
|
148
|
+
const turnId = this.current?.id;
|
|
149
|
+
if (!turnId || !this.threadId)
|
|
150
|
+
return 'notSent';
|
|
151
|
+
try {
|
|
152
|
+
await this.request('turn/steer', { threadId: this.threadId, expectedTurnId: turnId, input: [{ type: 'text', text: message.text }] });
|
|
153
|
+
return 'steered';
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return 'notSent';
|
|
157
|
+
}
|
|
158
|
+
}
|
|
146
159
|
async close() {
|
|
147
160
|
this.closing = true;
|
|
148
161
|
await this.process?.kill();
|
|
@@ -234,6 +247,8 @@ class CodexDriver {
|
|
|
234
247
|
const last = params.tokenUsage?.last;
|
|
235
248
|
if (last && this.current) {
|
|
236
249
|
this.current.usage.input += Number(last.inputTokens) || 0;
|
|
250
|
+
this.current.usage.cached += Number(last.cachedInputTokens) || 0;
|
|
251
|
+
this.current.usage.cacheWrite += Number(last.cacheWriteInputTokens) || 0;
|
|
237
252
|
this.current.usage.output += Number(last.outputTokens) || 0;
|
|
238
253
|
}
|
|
239
254
|
return;
|
|
@@ -255,7 +270,8 @@ class CodexDriver {
|
|
|
255
270
|
return;
|
|
256
271
|
this.current = null;
|
|
257
272
|
const status = turn.status === 'completed' ? 'completed' : turn.status === 'interrupted' ? 'interrupted' : 'failed';
|
|
258
|
-
const
|
|
273
|
+
const { usage } = current;
|
|
274
|
+
const stats = { inputTokens: usage.input, cachedInputTokens: usage.cached, cacheWriteTokens: usage.cacheWrite, outputTokens: usage.output };
|
|
259
275
|
current.resolve(status === 'failed'
|
|
260
276
|
? { status, error: codexErrorText(turn.error?.message) ?? current.error ?? 'The turn failed.', stats }
|
|
261
277
|
: { 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
|
@@ -44,7 +44,7 @@ export async function loadListenerId(appDirectory, baseUrl, userId) {
|
|
|
44
44
|
await writeFile(file, JSON.stringify({ id }, null, 2), { mode: 0o600 });
|
|
45
45
|
return id;
|
|
46
46
|
}
|
|
47
|
-
export async function runAgentsCommand(client, args, flags, appDirectory) {
|
|
47
|
+
export async function runAgentsCommand(client, args, flags, appDirectory, teamsWatch) {
|
|
48
48
|
const action = args[0];
|
|
49
49
|
if (action === 'status') {
|
|
50
50
|
const detected = await detectAgents();
|
|
@@ -74,10 +74,17 @@ export async function runAgentsCommand(client, args, flags, appDirectory) {
|
|
|
74
74
|
? 'Sandboxed: agents may read files, search and use the Skillshare MCP tools, but cannot change files or run commands that modify anything.'
|
|
75
75
|
: 'Agents run with your full local permissions and configuration, without permission prompts. Use --sandbox to keep them read-only.');
|
|
76
76
|
console.log('Sessions keep their agent running between messages and close it after 15 quiet minutes; the next message resumes it.');
|
|
77
|
+
// Teams 👀 sessions need a live Teams session, so the listener keeps it renewed unless told not to.
|
|
78
|
+
const teams = teamsWatch && flags['no-teams-watch'] !== 'true'
|
|
79
|
+
? teamsWatch(abort.signal, message => console.log(`[teams] ${message}`))
|
|
80
|
+
.catch(error => console.log(`[teams] Teams renewal stopped: ${error instanceof Error ? error.message : String(error)}`))
|
|
81
|
+
: Promise.resolve();
|
|
77
82
|
try {
|
|
78
83
|
await listen({ client, listenerId, name: flags.name, workdir, maxConcurrency: max, sandbox, signal: abort.signal, onProgress: console.log });
|
|
79
84
|
}
|
|
80
85
|
finally {
|
|
86
|
+
abort.abort();
|
|
87
|
+
await teams;
|
|
81
88
|
process.removeListener('SIGINT', stop);
|
|
82
89
|
process.removeListener('SIGTERM', stop);
|
|
83
90
|
}
|
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.`);
|