copilot-tap-extension 0.2.5 → 1.0.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.
package/README.md CHANGED
@@ -204,7 +204,7 @@ PLAN.md # ubiquitous language and design decisions
204
204
  | [Reference](./docs/reference.md) | Look up tool parameters, config fields, or the event pipeline |
205
205
  | [Use cases and patterns](./docs/use-cases.md) | Recipes for deploy watchers, PR monitors, log tailers, and more |
206
206
  | [Evals](./docs/evals.md) | Run or extend the automated test suite |
207
- | [Copilot instructions](./.github/copilot-instructions.md) | Understand or customize how the agent uses this extension |
207
+ | [Copilot instructions](./src/copilot-instructions.md) | Understand or customize how the agent uses this extension |
208
208
  | [Implementation plan](./PLAN.md) | Ubiquitous language and naming conventions for contributors |
209
209
  | [Evolution of the ※ icon](./docs/evolution-of-tap-icon.html) | 20 metaphors, 10 variants, one mark — the design story behind ※ tap |
210
210
 
package/bin/install.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, mkdirSync, copyFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, copyFileSync, readFileSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import path from "node:path";
5
5
  import os from "node:os";
@@ -11,6 +11,14 @@ const distDir = path.join(pkgRoot, "dist");
11
11
  const BRAND = "※ tap";
12
12
  const EXT_DIR_NAME = "tap";
13
13
 
14
+ function getPackageVersion() {
15
+ try {
16
+ return JSON.parse(readFileSync(path.join(distDir, "version.json"), "utf8")).version;
17
+ } catch {
18
+ return JSON.parse(readFileSync(path.join(pkgRoot, "package.json"), "utf8")).version;
19
+ }
20
+ }
21
+
14
22
  function usage() {
15
23
  console.log(`
16
24
  ${BRAND} — Copilot CLI extension installer
@@ -18,14 +26,18 @@ ${BRAND} — Copilot CLI extension installer
18
26
  Usage:
19
27
  npx copilot-tap-extension [options]
20
28
 
29
+ If ※ tap is already installed, updates core files (extension + version)
30
+ and preserves customizable artifacts. If fresh, does a full install.
31
+
21
32
  Options:
22
33
  --global, -g Install to ~/.copilot/ (default)
23
34
  --local, -l Install to .github/ (project-scoped)
24
- --force, -f Overwrite existing files without prompting
25
- --help, -h Show this help message
35
+ --full Force a full install even if already installed
36
+ --help, -h Show this help message
26
37
 
27
38
  Installs:
28
39
  extensions/tap/extension.mjs The bundled ※ tap extension
40
+ extensions/tap/version.json Installed version metadata
29
41
  skills/loop/SKILL.md The /loop skill for prompt-based loops
30
42
  copilot-instructions.md Agent instructions for using ※ tap
31
43
  `);
@@ -33,7 +45,7 @@ Installs:
33
45
 
34
46
  function parseArgs(argv) {
35
47
  const args = argv.slice(2);
36
- const flags = { scope: "global", force: false, help: false };
48
+ const flags = { scope: "global", full: false, help: false };
37
49
  for (const arg of args) {
38
50
  switch (arg) {
39
51
  case "--global":
@@ -44,9 +56,14 @@ function parseArgs(argv) {
44
56
  case "-l":
45
57
  flags.scope = "local";
46
58
  break;
59
+ case "--full":
60
+ flags.full = true;
61
+ break;
62
+ // Keep legacy flags working
47
63
  case "--force":
48
64
  case "-f":
49
- flags.force = true;
65
+ case "--update":
66
+ case "-u":
50
67
  break;
51
68
  case "--help":
52
69
  case "-h":
@@ -68,33 +85,63 @@ function getTargetRoot(scope) {
68
85
  return path.join(process.cwd(), ".github");
69
86
  }
70
87
 
71
- function copyArtifact(src, dest, label, flags) {
88
+ function copyArtifact(src, dest, label) {
72
89
  if (!existsSync(src)) {
73
90
  console.error(` ✗ ${label}: source not found (${src})`);
74
91
  return false;
75
92
  }
76
- if (existsSync(dest) && !flags.force) {
77
- console.log(` ⊘ ${label}: already exists, skipping (use --force to overwrite)`);
78
- return true;
79
- }
80
93
  mkdirSync(path.dirname(dest), { recursive: true });
81
94
  copyFileSync(src, dest);
82
95
  console.log(` ✓ ${label}`);
83
96
  return true;
84
97
  }
85
98
 
99
+ function getInstalledVersion(targetRoot) {
100
+ try {
101
+ const versionFile = path.join(targetRoot, "extensions", EXT_DIR_NAME, "version.json");
102
+ return JSON.parse(readFileSync(versionFile, "utf8")).version;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ function isAlreadyInstalled(targetRoot) {
109
+ return existsSync(path.join(targetRoot, "extensions", EXT_DIR_NAME, "extension.mjs"));
110
+ }
111
+
86
112
  function install(flags) {
87
113
  const targetRoot = getTargetRoot(flags.scope);
88
114
  const scopeLabel = flags.scope === "global" ? "global (~/.copilot)" : "local (.github)";
115
+ const packageVersion = getPackageVersion();
116
+ const installed = isAlreadyInstalled(targetRoot);
117
+ const isUpdate = installed && !flags.full;
118
+
119
+ if (isUpdate) {
120
+ const installedVersion = getInstalledVersion(targetRoot);
121
+ if (installedVersion && installedVersion === packageVersion) {
122
+ console.log(`\n${BRAND} — already up to date (v${installedVersion})\n`);
123
+ process.exit(0);
124
+ }
125
+ const fromLabel = installedVersion ? `v${installedVersion}` : "unknown";
126
+ console.log(`\n${BRAND} — updating ${fromLabel} → v${packageVersion} (${scopeLabel})\n`);
127
+ } else {
128
+ console.log(`\n${BRAND} — installing v${packageVersion} (${scopeLabel})\n`);
129
+ }
89
130
 
90
- console.log(`\n${BRAND} installing (${scopeLabel})\n`);
91
-
92
- const artifacts = [
131
+ const coreArtifacts = [
93
132
  {
94
133
  src: path.join(distDir, "extension.mjs"),
95
134
  dest: path.join(targetRoot, "extensions", EXT_DIR_NAME, "extension.mjs"),
96
135
  label: "extensions/tap/extension.mjs"
97
136
  },
137
+ {
138
+ src: path.join(distDir, "version.json"),
139
+ dest: path.join(targetRoot, "extensions", EXT_DIR_NAME, "version.json"),
140
+ label: "extensions/tap/version.json"
141
+ }
142
+ ];
143
+
144
+ const ancillaryArtifacts = [
98
145
  {
99
146
  src: path.join(distDir, "skills", "loop", "SKILL.md"),
100
147
  dest: path.join(targetRoot, "skills", "loop", "SKILL.md"),
@@ -107,18 +154,21 @@ function install(flags) {
107
154
  }
108
155
  ];
109
156
 
157
+ const artifacts = isUpdate ? coreArtifacts : [...coreArtifacts, ...ancillaryArtifacts];
158
+
110
159
  let allOk = true;
111
160
  for (const { src, dest, label } of artifacts) {
112
- if (!copyArtifact(src, dest, label, flags)) {
161
+ if (!copyArtifact(src, dest, label)) {
113
162
  allOk = false;
114
163
  }
115
164
  }
116
165
 
117
166
  console.log();
118
167
  if (allOk) {
119
- console.log(`✓ ${BRAND} installed to ${targetRoot}`);
168
+ const verb = isUpdate ? "updated" : "installed";
169
+ console.log(`✓ ${BRAND} ${verb} to ${targetRoot}`);
120
170
  } else {
121
- console.error(`⚠ Some artifacts could not be installed.`);
171
+ console.error(`⚠ Some artifacts could not be ${isUpdate ? "updated" : "installed"}.`);
122
172
  process.exit(1);
123
173
  }
124
174
  }
@@ -2,7 +2,7 @@
2
2
  // https://github.com/amitse/copilot-tap-extension
3
3
 
4
4
 
5
- // .github/extensions/tap/extension.mjs
5
+ // src/extension.mjs
6
6
  import { joinSession } from "@github/copilot-sdk/extension";
7
7
 
8
8
  // src/consts.mjs
@@ -13,7 +13,7 @@ var CONFIG_LOCATIONS = [
13
13
  CONFIG_FILENAME,
14
14
  `${GITHUB_DIR}${path.sep}${CONFIG_FILENAME}`
15
15
  ];
16
- var COPILOT_INSTRUCTIONS_PATH = `${GITHUB_DIR}/copilot-instructions.md`;
16
+ var COPILOT_INSTRUCTIONS_PATH = "src/copilot-instructions.md";
17
17
  var MAX_STREAM_ENTRIES = 200;
18
18
  var DEFAULT_STREAM = "main";
19
19
  var DEFAULT_STREAM_DESCRIPTION = "Extension events";
@@ -766,12 +766,6 @@ function createLineRouter({ streams, notifications, sessionPort }) {
766
766
  monitorName: emitter.name,
767
767
  stream: STREAM.PROMPT
768
768
  });
769
- notifications.enqueue({
770
- channel: emitter.stream,
771
- monitorName: emitter.name,
772
- stream: STREAM.PROMPT,
773
- text
774
- });
775
769
  }
776
770
  }
777
771
  return { handleLine, handleTextBlock, handlePromptResult, appendSystemMessage };
@@ -951,19 +945,11 @@ function createLifecycle({ lineRouter, sessionPort }) {
951
945
  }
952
946
  async function runPromptIteration(emitter) {
953
947
  try {
954
- const response = await sessionPort.sendAndWait(emitter.prompt);
948
+ await sessionPort.send(emitter.prompt);
955
949
  if (emitter.stopRequested) {
956
950
  return { ok: true };
957
951
  }
958
- const responseText = toText(response?.data?.content ?? response?.data ?? response);
959
- if (responseText.trim()) {
960
- lineRouter.handlePromptResult(emitter, responseText);
961
- } else {
962
- lineRouter.appendSystemMessage(
963
- emitter,
964
- `Emitter '${emitter.name}' received an empty response from prompt work.`
965
- );
966
- }
952
+ emitter.lineCount += 1;
967
953
  return { ok: true };
968
954
  } catch (error) {
969
955
  return {
@@ -1537,6 +1523,111 @@ function createTools(deps) {
1537
1523
  ];
1538
1524
  }
1539
1525
 
1526
+ // src/update/checker.mjs
1527
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync } from "node:fs";
1528
+ import { fileURLToPath } from "node:url";
1529
+ import path4 from "node:path";
1530
+ import os from "node:os";
1531
+ var PKG_NAME = "copilot-tap-extension";
1532
+ var REGISTRY_URL = `https://registry.npmjs.org/${PKG_NAME}/latest`;
1533
+ var UPDATE_STATE_DIR = path4.join(os.homedir(), ".copilot");
1534
+ var UPDATE_STATE_FILE = path4.join(UPDATE_STATE_DIR, ".tap-update-state.json");
1535
+ var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
1536
+ function getInstalledVersion() {
1537
+ try {
1538
+ const extensionDir = path4.dirname(fileURLToPath(import.meta.url));
1539
+ const candidates = [
1540
+ path4.join(extensionDir, "version.json"),
1541
+ path4.join(extensionDir, "..", "version.json"),
1542
+ path4.join(extensionDir, "..", "..", "dist", "version.json")
1543
+ ];
1544
+ for (const candidate of candidates) {
1545
+ if (existsSync2(candidate)) {
1546
+ return JSON.parse(readFileSync2(candidate, "utf8")).version;
1547
+ }
1548
+ }
1549
+ return null;
1550
+ } catch {
1551
+ return null;
1552
+ }
1553
+ }
1554
+ function readUpdateState() {
1555
+ try {
1556
+ return JSON.parse(readFileSync2(UPDATE_STATE_FILE, "utf8"));
1557
+ } catch {
1558
+ return {};
1559
+ }
1560
+ }
1561
+ function writeUpdateState(state) {
1562
+ try {
1563
+ mkdirSync(UPDATE_STATE_DIR, { recursive: true });
1564
+ writeFileSync2(UPDATE_STATE_FILE, JSON.stringify(state, null, 2) + "\n");
1565
+ } catch {
1566
+ }
1567
+ }
1568
+ function shouldCheck() {
1569
+ const state = readUpdateState();
1570
+ if (!state.lastCheckAt) {
1571
+ return true;
1572
+ }
1573
+ return Date.now() - state.lastCheckAt > CHECK_INTERVAL_MS;
1574
+ }
1575
+ function recordCheck(latest) {
1576
+ const state = readUpdateState();
1577
+ state.lastCheckAt = Date.now();
1578
+ if (latest) {
1579
+ state.latestVersion = latest;
1580
+ }
1581
+ writeUpdateState(state);
1582
+ }
1583
+ async function fetchLatestVersion() {
1584
+ const res = await fetch(REGISTRY_URL);
1585
+ if (!res.ok) {
1586
+ return null;
1587
+ }
1588
+ const data = await res.json();
1589
+ return data.version ?? null;
1590
+ }
1591
+ function isNewer(installed, latest) {
1592
+ if (!installed || !latest) {
1593
+ return !!latest;
1594
+ }
1595
+ const pa = installed.split(".").map(Number);
1596
+ const pb = latest.split(".").map(Number);
1597
+ for (let i = 0; i < 3; i++) {
1598
+ if ((pb[i] || 0) > (pa[i] || 0)) {
1599
+ return true;
1600
+ }
1601
+ if ((pb[i] || 0) < (pa[i] || 0)) {
1602
+ return false;
1603
+ }
1604
+ }
1605
+ return false;
1606
+ }
1607
+ async function checkForUpdate(sessionPort) {
1608
+ try {
1609
+ if (!shouldCheck()) {
1610
+ return;
1611
+ }
1612
+ const installed = getInstalledVersion();
1613
+ if (!installed) {
1614
+ return;
1615
+ }
1616
+ const latest = await fetchLatestVersion();
1617
+ if (!latest) {
1618
+ return;
1619
+ }
1620
+ recordCheck(latest);
1621
+ if (!isNewer(installed, latest)) {
1622
+ return;
1623
+ }
1624
+ await sessionPort.log(
1625
+ `Update available: v${installed} \u2192 v${latest}. Run \`npx ${PKG_NAME}\` to update.`
1626
+ );
1627
+ } catch {
1628
+ }
1629
+ }
1630
+
1540
1631
  // src/hooks.mjs
1541
1632
  function sessionInjectorSummary(streams) {
1542
1633
  const subscribed = streams.list().filter((stream) => stream.sessionInjector.enabled);
@@ -1589,6 +1680,8 @@ function createHooks({ streams, configStore, supervisor, sessionPort, setBaseCwd
1589
1680
  return {
1590
1681
  onSessionStart: async (input) => {
1591
1682
  streams.ensure(DEFAULT_STREAM, DEFAULT_STREAM_DESCRIPTION);
1683
+ checkForUpdate(sessionPort).catch(() => {
1684
+ });
1592
1685
  let configSummary = "No config loaded.";
1593
1686
  try {
1594
1687
  configSummary = await applyPersistentConfig({
@@ -1664,7 +1757,7 @@ function createCopilotChannelsRuntime(options = {}) {
1664
1757
  };
1665
1758
  }
1666
1759
 
1667
- // .github/extensions/tap/extension.mjs
1760
+ // src/extension.mjs
1668
1761
  var runtime = createCopilotChannelsRuntime({
1669
1762
  cwd: process.cwd()
1670
1763
  });
@@ -67,12 +67,11 @@ When this skill is invoked:
67
67
 
68
68
  ## Why subscribe defaults to false
69
69
 
70
- PromptEmitter output is already delivered through two paths:
70
+ PromptEmitter output is delivered through a single path:
71
71
 
72
- 1. **`session.sendAndWait()`** -- the prompt runs inside the session, so Copilot processes and responds to it directly.
73
- 2. **Notification dispatcher** -- each result line is also enqueued via `handlePromptResult` and injected as a background event stream update via `session.send()`.
72
+ 1. **`session.send()`** -- the prompt is dispatched fire-and-forget; Copilot processes and responds to it directly inside the session.
74
73
 
75
- The `subscribe` flag controls a third layer: the **SessionInjector**. When enabled, it additionally pushes system-level messages (emitter started, stopped, errored) into the session.
74
+ The `subscribe` flag controls the **SessionInjector**. When enabled, it additionally pushes system-level messages (emitter started, stopped, errored) into the session.
76
75
 
77
76
  For PromptEmitters, the main results already reach the session without the SessionInjector. Setting `subscribe = true` adds system noise on top of content that is already being delivered. Default to `false` to keep things clean.
78
77
 
@@ -0,0 +1,3 @@
1
+ {
2
+ "version": "1.0.0"
3
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copilot-tap-extension",
3
- "version": "0.2.5",
3
+ "version": "1.0.0",
4
4
  "description": "Copilot CLI extension for background event emitters, event streams, and session injection.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,7 +20,7 @@
20
20
  "scripts": {
21
21
  "build": "node scripts/build.mjs",
22
22
  "prepublishOnly": "npm run build",
23
- "check": "node --check ./src/tap-runtime.mjs && node --check ./.github/extensions/tap/extension.mjs && node --check ./evals/run.mjs && node --check ./examples/heartbeat.mjs",
23
+ "check": "node --check ./src/tap-runtime.mjs && node --check ./src/extension.mjs && node --check ./evals/run.mjs && node --check ./examples/heartbeat.mjs",
24
24
  "demo:heartbeat": "node ./examples/heartbeat.mjs",
25
25
  "evals:list": "node ./evals/run.mjs list",
26
26
  "evals:smoke": "node ./evals/run.mjs smoke",