heyio 0.5.0 → 0.8.0

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.
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Idle timeout helper for agent task execution (issue #53).
3
+ *
4
+ * The Copilot SDK's `sendAndWait(prompt, timeout)` enforces a wall-clock
5
+ * timeout. Long-running squad tasks were silently killed at 600s even when
6
+ * the agent was actively making progress (#42, #45). This helper replaces
7
+ * the wall-clock timeout with an **idle-reset** timeout: every progress
8
+ * event (tool execution, assistant message, turn boundary) resets the
9
+ * timer. The agent is only killed if it stops emitting events for `idleMs`
10
+ * — i.e. it is actually stuck, not just slow.
11
+ *
12
+ * On graceful timeout we capture the partial content emitted so far and
13
+ * surface it to the caller instead of throwing.
14
+ */
15
+ const PROGRESS_EVENT_TYPES = new Set([
16
+ "assistant.turn_start",
17
+ "assistant.message_delta",
18
+ "assistant.message",
19
+ "assistant.turn_end",
20
+ "assistant.reasoning",
21
+ "assistant.reasoning_delta",
22
+ "tool.execution_start",
23
+ "tool.execution_progress",
24
+ "tool.execution_partial_result",
25
+ "tool.execution_complete",
26
+ ]);
27
+ export async function sendWithIdleTimeout(session, prompt, opts) {
28
+ let accumulated = "";
29
+ let lastEventType;
30
+ let idleTimer;
31
+ let aborted = false;
32
+ let abortReason;
33
+ const triggerIdleAbort = () => {
34
+ if (aborted)
35
+ return;
36
+ aborted = true;
37
+ abortReason = "idle";
38
+ opts.onIdleTimeout?.({ lastEventType, idleMs: opts.idleMs });
39
+ void session.abort().catch(() => {
40
+ /* best-effort */
41
+ });
42
+ };
43
+ const resetIdle = () => {
44
+ if (idleTimer)
45
+ clearTimeout(idleTimer);
46
+ idleTimer = setTimeout(triggerIdleAbort, opts.idleMs);
47
+ };
48
+ const unsubDelta = session.on("assistant.message_delta", (event) => {
49
+ const delta = event?.data?.deltaContent;
50
+ if (typeof delta === "string")
51
+ accumulated += delta;
52
+ });
53
+ const unsubAll = session.on((event) => {
54
+ if (PROGRESS_EVENT_TYPES.has(event.type)) {
55
+ lastEventType = event.type;
56
+ opts.onProgress?.(event.type);
57
+ resetIdle();
58
+ }
59
+ });
60
+ resetIdle();
61
+ try {
62
+ const response = await session.sendAndWait({ prompt }, opts.hardCapMs);
63
+ if (aborted) {
64
+ return {
65
+ content: response?.data?.content ?? accumulated,
66
+ timedOut: true,
67
+ timeoutReason: abortReason,
68
+ lastEventType,
69
+ };
70
+ }
71
+ return {
72
+ content: response?.data?.content ?? accumulated,
73
+ timedOut: false,
74
+ lastEventType,
75
+ };
76
+ }
77
+ catch (err) {
78
+ const message = err instanceof Error ? err.message : String(err);
79
+ const looksLikeTimeout = /timeout/i.test(message);
80
+ if (aborted || looksLikeTimeout) {
81
+ if (!aborted && looksLikeTimeout) {
82
+ abortReason = "hard_cap";
83
+ opts.onHardCap?.();
84
+ }
85
+ return {
86
+ content: accumulated ||
87
+ `(no output captured before timeout; last event: ${lastEventType ?? "none"})`,
88
+ timedOut: true,
89
+ timeoutReason: abortReason ?? "hard_cap",
90
+ lastEventType,
91
+ };
92
+ }
93
+ throw err;
94
+ }
95
+ finally {
96
+ if (idleTimer)
97
+ clearTimeout(idleTimer);
98
+ try {
99
+ unsubDelta();
100
+ }
101
+ catch {
102
+ /* ignore */
103
+ }
104
+ try {
105
+ unsubAll();
106
+ }
107
+ catch {
108
+ /* ignore */
109
+ }
110
+ }
111
+ }
112
+ //# sourceMappingURL=session-timeout.js.map
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Tests for src/copilot/session-timeout.ts — sendWithIdleTimeout
3
+ *
4
+ * Strategy: build a FakeSession class whose sendAndWait() is controlled by
5
+ * a deferred Promise. Event handlers registered via .on() are stored in a
6
+ * subscriber list and can be triggered manually with .emit(). Timer
7
+ * behaviour is driven by node:test's mock.timers so no real setTimeout fires.
8
+ */
9
+ import { describe, it, before, after, beforeEach, mock } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { sendWithIdleTimeout, } from "./session-timeout.js";
12
+ /** Minimal fake implementing only what sendWithIdleTimeout needs. */
13
+ function makeFakeSession() {
14
+ const broadcastHandlers = [];
15
+ const deltaHandlers = [];
16
+ let resolveWait;
17
+ let rejectWait;
18
+ let aborted = false;
19
+ const session = {
20
+ // Typed overload: session.on("assistant.message_delta", handler)
21
+ // Generic overload: session.on(handler)
22
+ on(typeOrHandler, handler) {
23
+ if (typeof typeOrHandler === "function") {
24
+ broadcastHandlers.push(typeOrHandler);
25
+ return () => {
26
+ const idx = broadcastHandlers.indexOf(typeOrHandler);
27
+ if (idx !== -1)
28
+ broadcastHandlers.splice(idx, 1);
29
+ };
30
+ }
31
+ // typed: only care about delta for accumulation
32
+ if (typeOrHandler === "assistant.message_delta" && handler) {
33
+ deltaHandlers.push(handler);
34
+ return () => {
35
+ const idx = deltaHandlers.indexOf(handler);
36
+ if (idx !== -1)
37
+ deltaHandlers.splice(idx, 1);
38
+ };
39
+ }
40
+ return () => { };
41
+ },
42
+ sendAndWait(_opts, _timeout) {
43
+ return new Promise((resolve, reject) => {
44
+ resolveWait = resolve;
45
+ rejectWait = reject;
46
+ });
47
+ },
48
+ abort() {
49
+ aborted = true;
50
+ return Promise.resolve();
51
+ },
52
+ // ── Test-only helpers ──────────────────────────────────────────────────
53
+ /** Emit an event to all broad-listener handlers. */
54
+ emit(event) {
55
+ for (const h of broadcastHandlers)
56
+ h(event);
57
+ },
58
+ /** Emit an assistant.message_delta event with the given text chunk. */
59
+ emitDelta(deltaContent) {
60
+ const deltaEvent = {
61
+ type: "assistant.message_delta",
62
+ id: "evt-1",
63
+ timestamp: new Date().toISOString(),
64
+ parentId: null,
65
+ ephemeral: true,
66
+ data: { messageId: "msg-1", deltaContent },
67
+ };
68
+ // Notify delta-specific handlers
69
+ for (const h of deltaHandlers) {
70
+ h(deltaEvent);
71
+ }
72
+ // Also broadcast to all-event handlers so idle timer resets
73
+ this.emit(deltaEvent);
74
+ },
75
+ /** Emit a generic progress event (e.g. tool.execution_complete). */
76
+ emitProgress(type) {
77
+ const event = {
78
+ type,
79
+ id: "evt-2",
80
+ timestamp: new Date().toISOString(),
81
+ parentId: null,
82
+ ephemeral: false,
83
+ data: {},
84
+ };
85
+ this.emit(event);
86
+ },
87
+ /** Resolve the sendAndWait promise with a final content string. */
88
+ resolve(content) {
89
+ resolveWait?.({ data: { content } });
90
+ },
91
+ /** Resolve the sendAndWait promise with undefined (triggers accumulated fallback). */
92
+ resolveUndefined() {
93
+ resolveWait?.(undefined);
94
+ },
95
+ /** Reject the sendAndWait promise with a timeout-style error. */
96
+ rejectWithTimeout() {
97
+ rejectWait?.(new Error("Timeout after 600000ms waiting for session.idle"));
98
+ },
99
+ /** Reject the sendAndWait promise with a non-timeout error. */
100
+ rejectWithError(msg) {
101
+ rejectWait?.(new Error(msg));
102
+ },
103
+ get wasAborted() {
104
+ return aborted;
105
+ },
106
+ };
107
+ return session;
108
+ }
109
+ // Cast FakeSession to CopilotSession for type-compatibility with sendWithIdleTimeout.
110
+ // The fake satisfies the structural subset used by the function.
111
+ function asSession(fake) {
112
+ return fake;
113
+ }
114
+ // ── Helpers ──────────────────────────────────────────────────────────────────
115
+ const DEFAULT_OPTS = {
116
+ idleMs: 5_000,
117
+ hardCapMs: 30_000,
118
+ };
119
+ // ── Tests ─────────────────────────────────────────────────────────────────────
120
+ describe("sendWithIdleTimeout", () => {
121
+ before(() => {
122
+ mock.timers.enable({ apis: ["setTimeout"] });
123
+ });
124
+ after(() => {
125
+ mock.timers.reset();
126
+ });
127
+ beforeEach(() => {
128
+ // Reset the fake timer state between tests without disabling
129
+ mock.timers.reset();
130
+ mock.timers.enable({ apis: ["setTimeout"] });
131
+ });
132
+ // ── happy path ──────────────────────────────────────────────────────────
133
+ it("returns content and timedOut=false when session resolves normally", async () => {
134
+ const fake = makeFakeSession();
135
+ const promise = sendWithIdleTimeout(asSession(fake), "do something", DEFAULT_OPTS);
136
+ // Let the initial idle timer be registered, then resolve immediately
137
+ fake.resolve("task done successfully");
138
+ const result = await promise;
139
+ assert.equal(result.timedOut, false);
140
+ assert.equal(result.content, "task done successfully");
141
+ assert.equal(result.timeoutReason, undefined);
142
+ });
143
+ it("accumulates delta content during streaming (verified via resolveUndefined)", async () => {
144
+ const fake = makeFakeSession();
145
+ const promise = sendWithIdleTimeout(asSession(fake), "stream this", DEFAULT_OPTS);
146
+ fake.emitDelta("Hello ");
147
+ fake.emitDelta("world");
148
+ fake.emitDelta("!");
149
+ // resolveUndefined triggers `response?.data?.content ?? accumulated` → uses accumulated
150
+ fake.resolveUndefined();
151
+ const result = await promise;
152
+ assert.equal(result.timedOut, false);
153
+ assert.equal(result.content, "Hello world!");
154
+ });
155
+ it("falls back to accumulated delta when sendAndWait resolves with undefined", async () => {
156
+ const fake = makeFakeSession();
157
+ const promise = sendWithIdleTimeout(asSession(fake), "stream this", DEFAULT_OPTS);
158
+ fake.emitDelta("partial ");
159
+ fake.emitDelta("output");
160
+ // Resolve with undefined to trigger the `response?.data?.content ?? accumulated` fallback
161
+ fake.resolveUndefined();
162
+ const result = await promise;
163
+ assert.equal(result.timedOut, false);
164
+ assert.equal(result.content, "partial output");
165
+ });
166
+ // ── idle timer reset ────────────────────────────────────────────────────
167
+ it("resets idle timer on assistant.message_delta", async () => {
168
+ const fake = makeFakeSession();
169
+ const idleTimeoutFired = { value: false };
170
+ const opts = {
171
+ ...DEFAULT_OPTS,
172
+ idleMs: 1_000,
173
+ onIdleTimeout: () => { idleTimeoutFired.value = true; },
174
+ };
175
+ const promise = sendWithIdleTimeout(asSession(fake), "do work", opts);
176
+ // Advance to just before idle timeout — timer should NOT fire yet
177
+ mock.timers.tick(800);
178
+ assert.equal(idleTimeoutFired.value, false);
179
+ // Emit a progress event — should reset the idle timer
180
+ fake.emitProgress("tool.execution_complete");
181
+ // Advance another 800ms — if reset worked, timer still hasn't fired (800 < 1000)
182
+ mock.timers.tick(800);
183
+ assert.equal(idleTimeoutFired.value, false, "idle timer should have been reset by progress event");
184
+ // Resolve and clean up
185
+ fake.resolve("done");
186
+ await promise;
187
+ });
188
+ it("fires idle timeout after idleMs of silence", async () => {
189
+ const fake = makeFakeSession();
190
+ let idleFired = false;
191
+ const opts = {
192
+ ...DEFAULT_OPTS,
193
+ idleMs: 2_000,
194
+ onIdleTimeout: () => { idleFired = true; },
195
+ };
196
+ const promise = sendWithIdleTimeout(asSession(fake), "long task", opts);
197
+ // Advance past idle timeout — no events emitted
198
+ mock.timers.tick(2_001);
199
+ // Abort fires async; wait a tick then check
200
+ await Promise.resolve();
201
+ assert.equal(idleFired, true, "onIdleTimeout should have fired");
202
+ assert.equal(fake.wasAborted, true, "session.abort() should have been called");
203
+ // Simulate the sendAndWait timing out after abort
204
+ fake.rejectWithTimeout();
205
+ const result = await promise;
206
+ assert.equal(result.timedOut, true);
207
+ });
208
+ it("resets idle timer on tool.execution_complete", async () => {
209
+ const fake = makeFakeSession();
210
+ let idleFired = false;
211
+ const opts = {
212
+ ...DEFAULT_OPTS,
213
+ idleMs: 1_000,
214
+ onIdleTimeout: () => { idleFired = true; },
215
+ };
216
+ const promise = sendWithIdleTimeout(asSession(fake), "tool task", opts);
217
+ mock.timers.tick(700);
218
+ fake.emitProgress("tool.execution_complete");
219
+ mock.timers.tick(700); // 700ms since last reset — still under 1000ms
220
+ assert.equal(idleFired, false, "idle timer should have been reset by tool event");
221
+ fake.resolve("done");
222
+ await promise;
223
+ assert.equal(idleFired, false);
224
+ });
225
+ it("resets idle timer on assistant.turn_start", async () => {
226
+ const fake = makeFakeSession();
227
+ let idleFired = false;
228
+ const opts = {
229
+ ...DEFAULT_OPTS,
230
+ idleMs: 1_000,
231
+ onIdleTimeout: () => { idleFired = true; },
232
+ };
233
+ const promise = sendWithIdleTimeout(asSession(fake), "turn task", opts);
234
+ mock.timers.tick(700);
235
+ fake.emitProgress("assistant.turn_start");
236
+ mock.timers.tick(700);
237
+ assert.equal(idleFired, false, "idle timer should reset on assistant.turn_start");
238
+ fake.resolve("done");
239
+ await promise;
240
+ });
241
+ it("does NOT reset idle timer for unrecognised event types", async () => {
242
+ const fake = makeFakeSession();
243
+ let idleFired = false;
244
+ const opts = {
245
+ ...DEFAULT_OPTS,
246
+ idleMs: 1_000,
247
+ onIdleTimeout: () => { idleFired = true; },
248
+ };
249
+ const promise = sendWithIdleTimeout(asSession(fake), "noisy task", opts);
250
+ mock.timers.tick(700);
251
+ // This event type is not in PROGRESS_EVENT_TYPES
252
+ fake.emitProgress("some.unknown.event");
253
+ mock.timers.tick(400); // 700 + 400 = 1100ms since last real reset
254
+ await Promise.resolve();
255
+ assert.equal(idleFired, true, "idle timer should fire — unknown event should not reset it");
256
+ fake.rejectWithTimeout();
257
+ const result = await promise;
258
+ assert.equal(result.timedOut, true);
259
+ });
260
+ // ── graceful timeout capture ─────────────────────────────────────────────
261
+ it("captures partial content when idle timeout fires mid-stream", async () => {
262
+ const fake = makeFakeSession();
263
+ const opts = {
264
+ ...DEFAULT_OPTS,
265
+ idleMs: 1_000,
266
+ };
267
+ const promise = sendWithIdleTimeout(asSession(fake), "long output", opts);
268
+ // Agent emits some content then goes silent
269
+ fake.emitDelta("Step 1 done. ");
270
+ fake.emitDelta("Step 2 in progress...");
271
+ // Advance past idle timeout
272
+ mock.timers.tick(1_001);
273
+ await Promise.resolve();
274
+ // sendAndWait throws a timeout error after abort
275
+ fake.rejectWithTimeout();
276
+ const result = await promise;
277
+ assert.equal(result.timedOut, true);
278
+ // idle timer fires first → abortReason="idle"; catch block sees aborted=true and preserves it
279
+ assert.equal(result.timeoutReason, "idle", "idle fired before sendAndWait rejection → reason stays idle");
280
+ assert.ok(result.content.includes("Step 1 done.") && result.content.includes("Step 2 in progress..."), `partial content should be captured; got: ${result.content}`);
281
+ });
282
+ it("returns fallback message when no content was accumulated before timeout", async () => {
283
+ const fake = makeFakeSession();
284
+ const opts = { ...DEFAULT_OPTS, idleMs: 500 };
285
+ const promise = sendWithIdleTimeout(asSession(fake), "silent agent", opts);
286
+ mock.timers.tick(501);
287
+ await Promise.resolve();
288
+ fake.rejectWithTimeout();
289
+ const result = await promise;
290
+ assert.equal(result.timedOut, true);
291
+ assert.ok(result.content.includes("no output captured"), `should report no output captured; got: ${result.content}`);
292
+ });
293
+ it("sets timeoutReason=idle when abort fires before sendAndWait throws", async () => {
294
+ const fake = makeFakeSession();
295
+ let idleCallbackInfo;
296
+ const opts = {
297
+ ...DEFAULT_OPTS,
298
+ idleMs: 500,
299
+ onIdleTimeout: (info) => { idleCallbackInfo = info; },
300
+ };
301
+ const promise = sendWithIdleTimeout(asSession(fake), "silent", opts);
302
+ mock.timers.tick(501);
303
+ await Promise.resolve(); // let abort() fire
304
+ // When aborted=true, the resolve branch returns timedOut:true with timeoutReason from abortReason
305
+ fake.resolve("some final content");
306
+ const result = await promise;
307
+ assert.equal(result.timedOut, true);
308
+ assert.equal(result.timeoutReason, "idle");
309
+ assert.ok(idleCallbackInfo, "onIdleTimeout callback should have been called");
310
+ assert.equal(idleCallbackInfo?.idleMs, 500);
311
+ });
312
+ it("calls onProgress for each recognised event type", async () => {
313
+ const fake = makeFakeSession();
314
+ const progressEvents = [];
315
+ const opts = {
316
+ ...DEFAULT_OPTS,
317
+ onProgress: (type) => progressEvents.push(type),
318
+ };
319
+ const promise = sendWithIdleTimeout(asSession(fake), "track progress", opts);
320
+ fake.emitProgress("tool.execution_start");
321
+ fake.emitProgress("tool.execution_complete");
322
+ fake.emitDelta("some output");
323
+ fake.resolve("done");
324
+ await promise;
325
+ assert.ok(progressEvents.includes("tool.execution_start"));
326
+ assert.ok(progressEvents.includes("tool.execution_complete"));
327
+ assert.ok(progressEvents.includes("assistant.message_delta"));
328
+ });
329
+ it("tracks lastEventType in result", async () => {
330
+ const fake = makeFakeSession();
331
+ const promise = sendWithIdleTimeout(asSession(fake), "track last", DEFAULT_OPTS);
332
+ fake.emitProgress("tool.execution_start");
333
+ fake.emitProgress("tool.execution_complete");
334
+ fake.resolve("done");
335
+ const result = await promise;
336
+ assert.equal(result.lastEventType, "tool.execution_complete");
337
+ });
338
+ // ── error handling ───────────────────────────────────────────────────────
339
+ it("re-throws non-timeout errors", async () => {
340
+ const fake = makeFakeSession();
341
+ const promise = sendWithIdleTimeout(asSession(fake), "bad prompt", DEFAULT_OPTS);
342
+ fake.rejectWithError("unexpected authentication failure");
343
+ await assert.rejects(() => promise, (err) => {
344
+ assert.ok(err.message.includes("unexpected authentication failure"));
345
+ return true;
346
+ });
347
+ });
348
+ it("calls onHardCap when sendAndWait throws timeout and aborted=false", async () => {
349
+ const fake = makeFakeSession();
350
+ let hardCapFired = false;
351
+ const opts = {
352
+ ...DEFAULT_OPTS,
353
+ onHardCap: () => { hardCapFired = true; },
354
+ };
355
+ const promise = sendWithIdleTimeout(asSession(fake), "hard task", opts);
356
+ // Don't advance past idle timer — just let the hard cap throw
357
+ fake.rejectWithTimeout();
358
+ const result = await promise;
359
+ assert.equal(result.timedOut, true);
360
+ assert.equal(result.timeoutReason, "hard_cap");
361
+ assert.equal(hardCapFired, true);
362
+ });
363
+ it("cleans up subscriptions after normal completion (no memory leak)", async () => {
364
+ const fake = makeFakeSession();
365
+ const promise = sendWithIdleTimeout(asSession(fake), "cleanup test", DEFAULT_OPTS);
366
+ fake.resolve("done");
367
+ const result = await promise;
368
+ assert.equal(result.timedOut, false);
369
+ // If unsubscribe threw, the test would fail — we're asserting no throw
370
+ });
371
+ });
372
+ //# sourceMappingURL=session-timeout.test.js.map
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, readFileSync, rmSync, statSync } from "fs";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "fs";
2
2
  import { join, basename } from "path";
3
3
  import { execSync } from "child_process";
4
4
  import { SKILLS_DIR } from "../paths.js";
@@ -80,11 +80,85 @@ export function listSkills() {
80
80
  }
81
81
  return skills;
82
82
  }
83
+ const GITHUB_BLOB_RE = /^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/blob\/([^\/]+)\/(.+\/)?SKILL\.md$/i;
84
+ const RAW_GH_RE = /^https:\/\/raw\.githubusercontent\.com\/([^\/]+)\/([^\/]+)\/([^\/]+)\/(.+\/)?SKILL\.md$/i;
85
+ const GENERIC_SKILL_MD_RE = /\/SKILL\.md$/i;
86
+ function deriveSlug(repo, pathPrefix) {
87
+ if (!pathPrefix)
88
+ return repo;
89
+ const segments = pathPrefix.replace(/\/$/, "").split("/").filter(Boolean);
90
+ const last = segments[segments.length - 1];
91
+ return last ? `${repo}-${last}` : repo;
92
+ }
83
93
  /**
84
- * Clone a git repo into SKILLS_DIR and return the installed skill info.
85
- * Throws if the cloned repo does not contain a SKILL.md file.
94
+ * Determine whether the input URL points to a full repo or a specific
95
+ * SKILL.md file. For GitHub blob URLs the raw download URL is derived
96
+ * automatically.
86
97
  */
87
- export async function installSkill(repoUrl) {
98
+ export function parseSkillUrl(input) {
99
+ const blobMatch = input.match(GITHUB_BLOB_RE);
100
+ if (blobMatch) {
101
+ const [, owner, repo, branch, pathPrefix] = blobMatch;
102
+ const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${pathPrefix ?? ""}SKILL.md`;
103
+ return { type: "file", rawUrl, slug: deriveSlug(repo, pathPrefix) };
104
+ }
105
+ const rawMatch = input.match(RAW_GH_RE);
106
+ if (rawMatch) {
107
+ const [, _owner, repo, _branch, pathPrefix] = rawMatch;
108
+ return { type: "file", rawUrl: input, slug: deriveSlug(repo, pathPrefix) };
109
+ }
110
+ if (GENERIC_SKILL_MD_RE.test(input)) {
111
+ if (!input.startsWith("https://")) {
112
+ throw new Error("Only https:// URLs are supported for SKILL.md installs.");
113
+ }
114
+ const urlObj = new URL(input);
115
+ const segments = urlObj.pathname.split("/").filter(Boolean);
116
+ // Use the segment before SKILL.md, or the hostname as slug fallback
117
+ const slug = segments.length >= 2
118
+ ? segments[segments.length - 2]
119
+ : urlObj.hostname.replace(/\./g, "-");
120
+ return { type: "file", rawUrl: input, slug };
121
+ }
122
+ return { type: "repo", url: input };
123
+ }
124
+ async function installSkillFromFile(rawUrl, slug) {
125
+ if (!rawUrl.startsWith("https://")) {
126
+ throw new Error("Only https:// URLs are supported for SKILL.md installs.");
127
+ }
128
+ const destDir = join(SKILLS_DIR, slug);
129
+ if (existsSync(destDir)) {
130
+ throw new Error(`Skill "${slug}" is already installed.`);
131
+ }
132
+ const response = await fetch(rawUrl);
133
+ if (!response.ok) {
134
+ throw new Error(`Failed to fetch SKILL.md from ${rawUrl} (HTTP ${response.status})`);
135
+ }
136
+ const content = await response.text();
137
+ // Validate: at least one markdown heading in the first 10 lines
138
+ const first10 = content.split(/\r?\n/).slice(0, 10);
139
+ if (!first10.some((line) => /^#\s+/.test(line))) {
140
+ throw new Error("URL does not appear to contain a valid SKILL.md file.");
141
+ }
142
+ mkdirSync(destDir, { recursive: true });
143
+ writeFileSync(join(destDir, "SKILL.md"), content, "utf-8");
144
+ const { name, description } = parseSkillMd(content);
145
+ return {
146
+ name: name || slug,
147
+ slug,
148
+ description,
149
+ path: destDir,
150
+ };
151
+ }
152
+ /**
153
+ * Install a skill from a git repo URL or a direct SKILL.md file URL.
154
+ * Throws if the repo/file does not contain a valid SKILL.md.
155
+ */
156
+ export async function installSkill(input) {
157
+ const parsed = parseSkillUrl(input);
158
+ if (parsed.type === "file") {
159
+ return installSkillFromFile(parsed.rawUrl, parsed.slug);
160
+ }
161
+ const repoUrl = parsed.url;
88
162
  const repoName = basename(repoUrl, ".git").replace(/\.git$/, "");
89
163
  const destDir = join(SKILLS_DIR, repoName);
90
164
  execSync(`git clone ${repoUrl} ${destDir}`, { stdio: "pipe" });
@@ -88,24 +88,27 @@ Squads are persistent project teams with **named specialist agents**. Each squad
88
88
  Only specify an \`agent\` when the user **explicitly asks** to target a specific squad member by name.
89
89
 
90
90
  ### Team Leads
91
- Every squad should have a **team lead**. After building the team with \`squad_add_agent\`, designate one agent as the lead using \`squad_set_lead\`. The lead receives delegated tasks (when no specific agent is targeted), breaks them into subtasks, and assigns work to teammates via the lead-only \`delegate_to_teammate\` tool. This keeps coordination inside the squad rather than forcing IO to micro-manage assignments.
91
+ Every squad **must** have a **dedicated team lead** a PM / Senior Engineer whose **sole** responsibility is coordinating the team, delegating tasks, and reviewing results. The lead must NOT also own a hands-on engineering domain (no "Frontend Lead", "Test Manager", or "QA Lead" — those mix coordination with domain ownership). When building the squad, explicitly add a lead agent with a role title like "Senior Engineering Lead", "Project Manager", "Tech Lead", or "Principal Engineer" *in addition to* the domain specialists, then designate them with \`squad_set_lead\`. The lead receives delegated tasks (when no specific agent is targeted), breaks them into subtasks, assigns work to teammates via the lead-only \`delegate_to_teammate\` tool, and holds automatic veto power on PR promotion. This keeps coordination inside the squad rather than forcing IO to micro-manage assignments.
92
92
 
93
93
  ### Peer Review & QA Approvals
94
94
  When an agent finishes a task, the other squad members automatically review the work and vote APPROVED or REJECTED. Reviews are recorded and emitted as \`task.review\` events.
95
95
 
96
- - **Required**: every squad must have at least one agent designated as QA via \`squad_set_qa\`, AND at least one agent whose role title implies a testing/quality focus (e.g. role contains "test", "qa", or "quality"). Both can be the same agent.
97
- - \`squad_status\`, \`squad_agents\`, and \`squad_delegate\` will surface a ⚠️ warning when either is missing. Delegation is not blocked, but you should fix the gap before promoting work.
98
- - **QA agents and the team lead have veto power**: if any QA reviewer or the team lead rejects, the PR stays as a draft. The lead's veto is automatic — no need to also designate them as QA.
96
+ - **Required**: every squad must have (1) a **dedicated team lead** designated via \`squad_set_lead\` whose role is coordination-only with no domain ownership, (2) at least one agent designated as QA via \`squad_set_qa\`, and (3) at least one agent whose role title implies a testing/quality focus (e.g. role contains "test", "qa", or "quality"). The QA and test-engineer roles can be the same agent, but the lead must be separate from the domain specialists.
97
+ - \`squad_status\`, \`squad_agents\`, and \`squad_delegate\` will surface a ⚠️ warning when any of these are missing — including when a lead is set but their role title looks like a domain specialist. Delegation is not blocked, but you should fix the gap before promoting work.
98
+ - **QA agents, test engineers, and the team lead all have veto power**: if any of them rejects, the PR stays as a draft. The lead's veto is automatic — no need to also designate them as QA. Designating your test engineer as QA gives them the same explicit veto authority.
99
99
  - Non-QA rejections are advisory — they're recorded but don't block promotion.
100
100
  - When all QA approvals pass (or no QA agents exist) and the task result contains a GitHub PR URL, the PR is automatically promoted from draft to ready via \`gh pr ready\`.
101
101
  - Use \`squad_task_reviews\` to inspect the reviews on any completed task.
102
102
 
103
103
  ### Squad Build Checklist
104
104
  After \`squad_create\`, before delegating real work:
105
- 1. Add agents with \`squad_add_agent\` (use roles tailored to the project's stack).
106
- 2. Include at least one **test/quality engineer** role (e.g. "Integration Test Engineer", "QA Specialist", "Quality Reviewer").
107
- 3. Designate a team lead with \`squad_set_lead\`.
108
- 4. Designate at least one QA reviewer with \`squad_set_qa\` (often the same agent as the test engineer).
105
+ 1. Add domain-specialist agents with \`squad_add_agent\` (use roles tailored to the project's stack).
106
+ 2. Add a **dedicated team lead agent** with a coordination-only role like "Senior Engineering Lead", "Project Manager", "Tech Lead", or "Principal Engineer". The lead must NOT also own a hands-on domain (no "Frontend Lead" — that's still a frontend engineer).
107
+ 3. Include at least one **test/quality engineer** role (e.g. "Integration Test Engineer", "QA Specialist", "Quality Reviewer"). This is a separate agent from the lead. Their charter should explicitly own the project's test suite — for the IO squad this means owning \`src/**/*.test.ts\` plus running \`npm run build\` / \`vue-tsc\` on every PR before promotion.
108
+ 4. Designate the team lead with \`squad_set_lead\`. The lead automatically holds veto power on PR promotion.
109
+ 5. Designate at least one QA reviewer with \`squad_set_qa\` (often the same agent as the test engineer). QA reviewers also hold veto power.
110
+
111
+ **No exemptions.** The squad that owns the IO codebase itself (\`michaeljolley-io\`) is held to the same checklist as every other squad. If \`squad_status\` ever shows a coverage warning for the IO squad, fix it before shipping further work — IO does not get to ship rules it doesn't follow.
109
112
 
110
113
  ### Scheduled Stand-ups
111
114
  Squads can be put on a recurring cron-style schedule. At the scheduled time IO wakes the team lead, who runs the agenda by delegating to teammates. This runs in the background even when no human is in the TUI/Telegram.
@@ -187,6 +190,7 @@ The model is selected automatically. Tell the user which model tier was chosen w
187
190
  7. **Use your tools proactively.** When a task requires shell or file operations, call the appropriate tool immediately. Do not describe what command you *would* run — just run it. For git operations, use the \`shell\` tool. For file operations, use \`file_ops\` or \`shell\`.
188
191
  8. **Never fabricate errors.** Only report errors that a tool actually returned. If you haven't called a tool, you don't know whether it will succeed or fail.
189
192
  9. **Prefer your custom tools over built-in tools.** Always use \`shell\` instead of \`bash\`. Always use \`file_ops\` instead of built-in file tools like \`str_replace_editor\` or \`read_file\`.
193
+ 10. **Pull main before starting code work.** Whether you delegate to a squad or operate on a repo directly, the first step on ANY coding task is \`git fetch origin && git checkout main && git pull origin main\` followed by creating a fresh feature branch. Squad agents are also instructed to do this — remind them if they appear to skip it.
190
194
  ${selfEditBlock}${memoryBlock}`;
191
195
  }
192
196
  //# sourceMappingURL=system-message.js.map