@github/copilot-sdk 0.1.32 → 0.1.33-unstable.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,681 @@
1
+ # Copilot CLI Extension Examples
2
+
3
+ A practical guide to writing extensions using the `@github/copilot-sdk` extension API.
4
+
5
+ ## Extension Skeleton
6
+
7
+ Every extension starts with the same boilerplate:
8
+
9
+ ```js
10
+ import { approveAll } from "@github/copilot-sdk";
11
+ import { joinSession } from "@github/copilot-sdk/extension";
12
+
13
+ const session = await joinSession({
14
+ onPermissionRequest: approveAll,
15
+ hooks: { /* ... */ },
16
+ tools: [ /* ... */ ],
17
+ });
18
+ ```
19
+
20
+ `joinSession` returns a `CopilotSession` object you can use to send messages and subscribe to events.
21
+
22
+ > **Platform notes (Windows vs macOS/Linux):**
23
+ > - Use `process.platform === "win32"` to detect Windows at runtime.
24
+ > - Clipboard: `pbcopy` on macOS, `clip` on Windows.
25
+ > - Use `exec()` instead of `execFile()` for `.cmd` scripts like `code`, `npx`, `npm` on Windows.
26
+ > - PowerShell stderr redirection uses `*>&1` instead of `2>&1`.
27
+
28
+ ---
29
+
30
+ ## Logging to the Timeline
31
+
32
+ Use `session.log()` to surface messages to the user in the CLI timeline:
33
+
34
+ ```js
35
+ const session = await joinSession({
36
+ onPermissionRequest: approveAll,
37
+ hooks: {
38
+ onSessionStart: async () => {
39
+ await session.log("My extension loaded");
40
+ },
41
+ onPreToolUse: async (input) => {
42
+ if (input.toolName === "bash") {
43
+ await session.log(`Running: ${input.toolArgs?.command}`, { ephemeral: true });
44
+ }
45
+ },
46
+ },
47
+ tools: [],
48
+ });
49
+ ```
50
+
51
+ Levels: `"info"` (default), `"warning"`, `"error"`. Set `ephemeral: true` for transient messages that aren't persisted.
52
+
53
+ ---
54
+
55
+ ## Registering Custom Tools
56
+
57
+ Tools are functions the agent can call. Define them with a name, description, JSON Schema parameters, and a handler.
58
+
59
+ ### Basic tool
60
+
61
+ ```js
62
+ tools: [
63
+ {
64
+ name: "my_tool",
65
+ description: "Does something useful",
66
+ parameters: {
67
+ type: "object",
68
+ properties: {
69
+ input: { type: "string", description: "The input value" },
70
+ },
71
+ required: ["input"],
72
+ },
73
+ handler: async (args) => {
74
+ return `Processed: ${args.input}`;
75
+ },
76
+ },
77
+ ]
78
+ ```
79
+
80
+ ### Tool that invokes an external shell command
81
+
82
+ ```js
83
+ import { execFile } from "node:child_process";
84
+
85
+ {
86
+ name: "run_command",
87
+ description: "Runs a shell command and returns its output",
88
+ parameters: {
89
+ type: "object",
90
+ properties: {
91
+ command: { type: "string", description: "The command to run" },
92
+ },
93
+ required: ["command"],
94
+ },
95
+ handler: async (args) => {
96
+ const isWindows = process.platform === "win32";
97
+ const shell = isWindows ? "powershell" : "bash";
98
+ const shellArgs = isWindows
99
+ ? ["-NoProfile", "-Command", args.command]
100
+ : ["-c", args.command];
101
+ return new Promise((resolve) => {
102
+ execFile(shell, shellArgs, (err, stdout, stderr) => {
103
+ if (err) resolve(`Error: ${stderr || err.message}`);
104
+ else resolve(stdout);
105
+ });
106
+ });
107
+ },
108
+ }
109
+ ```
110
+
111
+ ### Tool that calls an external API
112
+
113
+ ```js
114
+ {
115
+ name: "fetch_data",
116
+ description: "Fetches data from an API endpoint",
117
+ parameters: {
118
+ type: "object",
119
+ properties: {
120
+ url: { type: "string", description: "The URL to fetch" },
121
+ },
122
+ required: ["url"],
123
+ },
124
+ handler: async (args) => {
125
+ const res = await fetch(args.url);
126
+ if (!res.ok) return `Error: HTTP ${res.status}`;
127
+ return await res.text();
128
+ },
129
+ }
130
+ ```
131
+
132
+ ### Tool handler invocation context
133
+
134
+ The handler receives a second argument with invocation metadata:
135
+
136
+ ```js
137
+ handler: async (args, invocation) => {
138
+ // invocation.sessionId — current session ID
139
+ // invocation.toolCallId — unique ID for this tool call
140
+ // invocation.toolName — name of the tool being called
141
+ return "done";
142
+ }
143
+ ```
144
+
145
+ ---
146
+
147
+ ## Hooks
148
+
149
+ Hooks intercept and modify behavior at key lifecycle points. Register them in the `hooks` option.
150
+
151
+ ### Available Hooks
152
+
153
+ | Hook | Fires When | Can Modify |
154
+ |------|-----------|------------|
155
+ | `onUserPromptSubmitted` | User sends a message | The prompt text, add context |
156
+ | `onPreToolUse` | Before a tool executes | Tool args, permission decision, add context |
157
+ | `onPostToolUse` | After a tool executes | Tool result, add context |
158
+ | `onSessionStart` | Session starts or resumes | Add context, modify config |
159
+ | `onSessionEnd` | Session ends | Cleanup actions, summary |
160
+ | `onErrorOccurred` | An error occurs | Error handling strategy (retry/skip/abort) |
161
+
162
+ All hook inputs include `timestamp` (unix ms) and `cwd` (working directory).
163
+
164
+ ### Modifying the user's message
165
+
166
+ Use `onUserPromptSubmitted` to rewrite or augment what the user typed before the agent sees it.
167
+
168
+ ```js
169
+ hooks: {
170
+ onUserPromptSubmitted: async (input) => {
171
+ // Rewrite the prompt
172
+ return { modifiedPrompt: input.prompt.toUpperCase() };
173
+ },
174
+ }
175
+ ```
176
+
177
+ ### Injecting additional context into every message
178
+
179
+ Return `additionalContext` to silently append instructions the agent will follow.
180
+
181
+ ```js
182
+ hooks: {
183
+ onUserPromptSubmitted: async (input) => {
184
+ return {
185
+ additionalContext: "Always respond in bullet points. Follow our team coding standards.",
186
+ };
187
+ },
188
+ }
189
+ ```
190
+
191
+ ### Sending a follow-up message based on a keyword
192
+
193
+ Use `session.send()` to programmatically inject a new user message.
194
+
195
+ ```js
196
+ hooks: {
197
+ onUserPromptSubmitted: async (input) => {
198
+ if (/\\burgent\\b/i.test(input.prompt)) {
199
+ // Fire-and-forget a follow-up message
200
+ setTimeout(() => session.send({ prompt: "Please prioritize this." }), 0);
201
+ }
202
+ },
203
+ }
204
+ ```
205
+
206
+ > **Tip:** Guard against infinite loops if your follow-up message could re-trigger the same hook.
207
+
208
+ ### Blocking dangerous tool calls
209
+
210
+ Use `onPreToolUse` to inspect and optionally deny tool execution.
211
+
212
+ ```js
213
+ hooks: {
214
+ onPreToolUse: async (input) => {
215
+ if (input.toolName === "bash") {
216
+ const cmd = String(input.toolArgs?.command || "");
217
+ if (/rm\\s+-rf/i.test(cmd) || /Remove-Item\\s+.*-Recurse/i.test(cmd)) {
218
+ return {
219
+ permissionDecision: "deny",
220
+ permissionDecisionReason: "Destructive commands are not allowed.",
221
+ };
222
+ }
223
+ }
224
+ // Allow everything else
225
+ return { permissionDecision: "allow" };
226
+ },
227
+ }
228
+ ```
229
+
230
+ ### Modifying tool arguments before execution
231
+
232
+ ```js
233
+ hooks: {
234
+ onPreToolUse: async (input) => {
235
+ if (input.toolName === "bash") {
236
+ const redirect = process.platform === "win32" ? "*>&1" : "2>&1";
237
+ return {
238
+ modifiedArgs: {
239
+ ...input.toolArgs,
240
+ command: `${input.toolArgs.command} ${redirect}`,
241
+ },
242
+ };
243
+ }
244
+ },
245
+ }
246
+ ```
247
+
248
+ ### Reacting when the agent creates or edits a file
249
+
250
+ Use `onPostToolUse` to run side effects after a tool completes.
251
+
252
+ ```js
253
+ import { exec } from "node:child_process";
254
+
255
+ hooks: {
256
+ onPostToolUse: async (input) => {
257
+ if (input.toolName === "create" || input.toolName === "edit") {
258
+ const filePath = input.toolArgs?.path;
259
+ if (filePath) {
260
+ // Open the file in VS Code
261
+ exec(`code "${filePath}"`, () => {});
262
+ }
263
+ }
264
+ },
265
+ }
266
+ ```
267
+
268
+ ### Augmenting tool results with extra context
269
+
270
+ ```js
271
+ hooks: {
272
+ onPostToolUse: async (input) => {
273
+ if (input.toolName === "bash" && input.toolResult?.resultType === "failure") {
274
+ return {
275
+ additionalContext: "The command failed. Try a different approach.",
276
+ };
277
+ }
278
+ },
279
+ }
280
+ ```
281
+
282
+ ### Running a linter after every file edit
283
+
284
+ ```js
285
+ import { exec } from "node:child_process";
286
+
287
+ hooks: {
288
+ onPostToolUse: async (input) => {
289
+ if (input.toolName === "edit") {
290
+ const filePath = input.toolArgs?.path;
291
+ if (filePath?.endsWith(".ts")) {
292
+ const result = await new Promise((resolve) => {
293
+ exec(`npx eslint "${filePath}"`, (err, stdout) => {
294
+ resolve(err ? stdout : "No lint errors.");
295
+ });
296
+ });
297
+ return { additionalContext: `Lint result: ${result}` };
298
+ }
299
+ }
300
+ },
301
+ }
302
+ ```
303
+
304
+ ### Handling errors with retry logic
305
+
306
+ ```js
307
+ hooks: {
308
+ onErrorOccurred: async (input) => {
309
+ if (input.recoverable && input.errorContext === "model_call") {
310
+ return { errorHandling: "retry", retryCount: 2 };
311
+ }
312
+ return {
313
+ errorHandling: "abort",
314
+ userNotification: `An error occurred: ${input.error}`,
315
+ };
316
+ },
317
+ }
318
+ ```
319
+
320
+ ### Session lifecycle hooks
321
+
322
+ ```js
323
+ hooks: {
324
+ onSessionStart: async (input) => {
325
+ // input.source is "startup", "resume", or "new"
326
+ return { additionalContext: "Remember to write tests for all changes." };
327
+ },
328
+ onSessionEnd: async (input) => {
329
+ // input.reason is "complete", "error", "abort", "timeout", or "user_exit"
330
+ },
331
+ }
332
+ ```
333
+
334
+ ---
335
+
336
+ ## Session Events
337
+
338
+ After calling `joinSession`, use `session.on()` to react to events in real time.
339
+
340
+ ### Listening to a specific event type
341
+
342
+ ```js
343
+ session.on("assistant.message", (event) => {
344
+ // event.data.content has the agent's response text
345
+ });
346
+ ```
347
+
348
+ ### Listening to all events
349
+
350
+ ```js
351
+ session.on((event) => {
352
+ // event.type and event.data are available for all events
353
+ });
354
+ ```
355
+
356
+ ### Unsubscribing from events
357
+
358
+ `session.on()` returns an unsubscribe function:
359
+
360
+ ```js
361
+ const unsubscribe = session.on("tool.execution_complete", (event) => {
362
+ // event.data.toolName, event.data.success, event.data.result, event.data.error
363
+ });
364
+
365
+ // Later, stop listening
366
+ unsubscribe();
367
+ ```
368
+
369
+ ### Example: Auto-copy agent responses to clipboard
370
+
371
+ Combine a hook (to detect a keyword) with a session event (to capture the response):
372
+
373
+ ```js
374
+ import { execFile } from "node:child_process";
375
+
376
+ let copyNextResponse = false;
377
+
378
+ function copyToClipboard(text) {
379
+ const cmd = process.platform === "win32" ? "clip" : "pbcopy";
380
+ const proc = execFile(cmd, [], () => {});
381
+ proc.stdin.write(text);
382
+ proc.stdin.end();
383
+ }
384
+
385
+ const session = await joinSession({
386
+ onPermissionRequest: approveAll,
387
+ hooks: {
388
+ onUserPromptSubmitted: async (input) => {
389
+ if (/\\bcopy\\b/i.test(input.prompt)) {
390
+ copyNextResponse = true;
391
+ }
392
+ },
393
+ },
394
+ tools: [],
395
+ });
396
+
397
+ session.on("assistant.message", (event) => {
398
+ if (copyNextResponse) {
399
+ copyNextResponse = false;
400
+ copyToClipboard(event.data.content);
401
+ }
402
+ });
403
+ ```
404
+
405
+ ### Top 10 Most Useful Event Types
406
+
407
+ | Event Type | Description | Key Data Fields |
408
+ |-----------|-------------|-----------------|
409
+ | `assistant.message` | Agent's final response | `content`, `messageId`, `toolRequests` |
410
+ | `assistant.streaming_delta` | Token-by-token streaming (ephemeral) | `totalResponseSizeBytes` |
411
+ | `tool.execution_start` | A tool is about to run | `toolCallId`, `toolName`, `arguments` |
412
+ | `tool.execution_complete` | A tool finished running | `toolCallId`, `toolName`, `success`, `result`, `error` |
413
+ | `user.message` | User sent a message | `content`, `attachments`, `source` |
414
+ | `session.idle` | Session finished processing a turn | `backgroundTasks` |
415
+ | `session.error` | An error occurred | `errorType`, `message`, `stack` |
416
+ | `permission.requested` | Agent needs permission (shell, file write, etc.) | `requestId`, `permissionRequest.kind` |
417
+ | `session.shutdown` | Session is ending | `shutdownType`, `totalPremiumRequests`, `codeChanges` |
418
+ | `assistant.turn_start` | Agent begins a new thinking/response cycle | `turnId` |
419
+
420
+ ### Example: Detecting when the plan file is created or edited
421
+
422
+ Use `session.workspacePath` to locate the session's `plan.md`, then `fs.watchFile` to detect changes.
423
+ Correlate `tool.execution_start` / `tool.execution_complete` events by `toolCallId` to distinguish agent edits from user edits.
424
+
425
+ ```js
426
+ import { existsSync, watchFile, readFileSync } from "node:fs";
427
+ import { join } from "node:path";
428
+ import { approveAll } from "@github/copilot-sdk";
429
+ import { joinSession } from "@github/copilot-sdk/extension";
430
+
431
+ const agentEdits = new Set(); // toolCallIds for in-flight agent edits
432
+ const recentAgentPaths = new Set(); // paths recently written by the agent
433
+
434
+ const session = await joinSession({
435
+ onPermissionRequest: approveAll,
436
+ });
437
+
438
+ const workspace = session.workspacePath; // e.g. ~/.copilot/session-state/<id>
439
+ if (workspace) {
440
+ const planPath = join(workspace, "plan.md");
441
+ let lastContent = existsSync(planPath) ? readFileSync(planPath, "utf-8") : null;
442
+
443
+ // Track agent edits to suppress false triggers
444
+ session.on("tool.execution_start", (event) => {
445
+ if ((event.data.toolName === "edit" || event.data.toolName === "create")
446
+ && String(event.data.arguments?.path || "").endsWith("plan.md")) {
447
+ agentEdits.add(event.data.toolCallId);
448
+ recentAgentPaths.add(planPath);
449
+ }
450
+ });
451
+ session.on("tool.execution_complete", (event) => {
452
+ if (agentEdits.delete(event.data.toolCallId)) {
453
+ setTimeout(() => {
454
+ recentAgentPaths.delete(planPath);
455
+ lastContent = existsSync(planPath) ? readFileSync(planPath, "utf-8") : null;
456
+ }, 2000);
457
+ }
458
+ });
459
+
460
+ watchFile(planPath, { interval: 1000 }, () => {
461
+ if (recentAgentPaths.has(planPath) || agentEdits.size > 0) return;
462
+ const content = existsSync(planPath) ? readFileSync(planPath, "utf-8") : null;
463
+ if (content === lastContent) return;
464
+ const wasCreated = lastContent === null && content !== null;
465
+ lastContent = content;
466
+ if (content !== null) {
467
+ session.send({
468
+ prompt: `The plan was ${wasCreated ? "created" : "edited"} by the user.`,
469
+ });
470
+ }
471
+ });
472
+ }
473
+ ```
474
+
475
+ ### Example: Reacting when the user manually edits any file in the repo
476
+
477
+ Use `fs.watch` with `recursive: true` on `process.cwd()` to detect file changes.
478
+ Filter out agent edits by tracking `tool.execution_start` / `tool.execution_complete` events.
479
+
480
+ ```js
481
+ import { watch, readFileSync, statSync } from "node:fs";
482
+ import { join, relative, resolve } from "node:path";
483
+ import { approveAll } from "@github/copilot-sdk";
484
+ import { joinSession } from "@github/copilot-sdk/extension";
485
+
486
+ const agentEditPaths = new Set();
487
+
488
+ const session = await joinSession({
489
+ onPermissionRequest: approveAll,
490
+ });
491
+
492
+ const cwd = process.cwd();
493
+ const IGNORE = new Set(["node_modules", ".git", "dist"]);
494
+
495
+ // Track agent file edits
496
+ session.on("tool.execution_start", (event) => {
497
+ if (event.data.toolName === "edit" || event.data.toolName === "create") {
498
+ const p = String(event.data.arguments?.path || "");
499
+ if (p) agentEditPaths.add(resolve(p));
500
+ }
501
+ });
502
+ session.on("tool.execution_complete", (event) => {
503
+ // Clear after a delay to avoid race with fs.watch
504
+ const p = [...agentEditPaths].find((x) => x); // any tracked path
505
+ setTimeout(() => agentEditPaths.clear(), 3000);
506
+ });
507
+
508
+ const debounce = new Map();
509
+
510
+ watch(cwd, { recursive: true }, (eventType, filename) => {
511
+ if (!filename || eventType !== "change") return;
512
+ if (filename.split(/[\\\\\\/]/).some((p) => IGNORE.has(p))) return;
513
+
514
+ if (debounce.has(filename)) clearTimeout(debounce.get(filename));
515
+ debounce.set(filename, setTimeout(() => {
516
+ debounce.delete(filename);
517
+ const fullPath = join(cwd, filename);
518
+ if (agentEditPaths.has(resolve(fullPath))) return;
519
+
520
+ try { if (!statSync(fullPath).isFile()) return; } catch { return; }
521
+ const relPath = relative(cwd, fullPath);
522
+ session.send({
523
+ prompt: `The user edited \\`${relPath}\\`.`,
524
+ attachments: [{ type: "file", path: fullPath }],
525
+ });
526
+ }, 500));
527
+ });
528
+ ```
529
+
530
+ ---
531
+
532
+ ## Sending Messages Programmatically
533
+
534
+ ### Fire-and-forget
535
+
536
+ ```js
537
+ await session.send({ prompt: "Analyze the test results." });
538
+ ```
539
+
540
+ ### Send and wait for the response
541
+
542
+ ```js
543
+ const response = await session.sendAndWait({ prompt: "What is 2 + 2?" });
544
+ // response?.data.content contains the agent's reply
545
+ ```
546
+
547
+ ### Send with file attachments
548
+
549
+ ```js
550
+ await session.send({
551
+ prompt: "Review this file",
552
+ attachments: [
553
+ { type: "file", path: "./src/index.ts" },
554
+ ],
555
+ });
556
+ ```
557
+
558
+ ---
559
+
560
+ ## Permission and User Input Handlers
561
+
562
+ ### Custom permission logic
563
+
564
+ ```js
565
+ const session = await joinSession({
566
+ onPermissionRequest: async (request) => {
567
+ if (request.kind === "shell") {
568
+ // request.fullCommandText has the shell command
569
+ return { kind: "approved" };
570
+ }
571
+ if (request.kind === "write") {
572
+ return { kind: "approved" };
573
+ }
574
+ return { kind: "denied-by-rules" };
575
+ },
576
+ });
577
+ ```
578
+
579
+ ### Handling agent questions (ask_user)
580
+
581
+ Register `onUserInputRequest` to enable the agent's `ask_user` tool:
582
+
583
+ ```js
584
+ const session = await joinSession({
585
+ onPermissionRequest: approveAll,
586
+ onUserInputRequest: async (request) => {
587
+ // request.question has the agent's question
588
+ // request.choices has the options (if multiple choice)
589
+ return { answer: "yes", wasFreeform: false };
590
+ },
591
+ });
592
+ ```
593
+
594
+ ---
595
+
596
+ ## Complete Example: Multi-Feature Extension
597
+
598
+ An extension that combines tools, hooks, and events.
599
+
600
+ ```js
601
+ import { execFile, exec } from "node:child_process";
602
+ import { approveAll } from "@github/copilot-sdk";
603
+ import { joinSession } from "@github/copilot-sdk/extension";
604
+
605
+ const isWindows = process.platform === "win32";
606
+ let copyNextResponse = false;
607
+
608
+ function copyToClipboard(text) {
609
+ const proc = execFile(isWindows ? "clip" : "pbcopy", [], () => {});
610
+ proc.stdin.write(text);
611
+ proc.stdin.end();
612
+ }
613
+
614
+ function openInEditor(filePath) {
615
+ if (isWindows) exec(`code "${filePath}"`, () => {});
616
+ else execFile("code", [filePath], () => {});
617
+ }
618
+
619
+ const session = await joinSession({
620
+ onPermissionRequest: approveAll,
621
+ hooks: {
622
+ onUserPromptSubmitted: async (input) => {
623
+ if (/\\bcopy this\\b/i.test(input.prompt)) {
624
+ copyNextResponse = true;
625
+ }
626
+ return {
627
+ additionalContext: "Follow our team style guide. Use 4-space indentation.",
628
+ };
629
+ },
630
+ onPreToolUse: async (input) => {
631
+ if (input.toolName === "bash") {
632
+ const cmd = String(input.toolArgs?.command || "");
633
+ if (/rm\\s+-rf\\s+\\//i.test(cmd) || /Remove-Item\\s+.*-Recurse/i.test(cmd)) {
634
+ return { permissionDecision: "deny" };
635
+ }
636
+ }
637
+ },
638
+ onPostToolUse: async (input) => {
639
+ if (input.toolName === "create" || input.toolName === "edit") {
640
+ const filePath = input.toolArgs?.path;
641
+ if (filePath) openInEditor(filePath);
642
+ }
643
+ },
644
+ },
645
+ tools: [
646
+ {
647
+ name: "copy_to_clipboard",
648
+ description: "Copies text to the system clipboard.",
649
+ parameters: {
650
+ type: "object",
651
+ properties: {
652
+ text: { type: "string", description: "Text to copy" },
653
+ },
654
+ required: ["text"],
655
+ },
656
+ handler: async (args) => {
657
+ return new Promise((resolve) => {
658
+ const proc = execFile(isWindows ? "clip" : "pbcopy", [], (err) => {
659
+ if (err) resolve(`Error: ${err.message}`);
660
+ else resolve("Copied to clipboard.");
661
+ });
662
+ proc.stdin.write(args.text);
663
+ proc.stdin.end();
664
+ });
665
+ },
666
+ },
667
+ ],
668
+ });
669
+
670
+ session.on("assistant.message", (event) => {
671
+ if (copyNextResponse) {
672
+ copyNextResponse = false;
673
+ copyToClipboard(event.data.content);
674
+ }
675
+ });
676
+
677
+ session.on("tool.execution_complete", (event) => {
678
+ // event.data.success, event.data.toolName, event.data.result
679
+ });
680
+ ```
681
+