fellow-agents 0.0.16 → 0.0.19

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.
@@ -97,14 +97,20 @@ export async function start(opts) {
97
97
  // 5. Install AI skills (SKILL.md files) to known CLI paths
98
98
  console.log("[5/8] Installing skills...");
99
99
  const skillResult = installSkills();
100
- if (skillResult.written.length > 0) {
101
- console.log(` Installed ${skillResult.written.length} skill file(s)`);
100
+ const skillTotal = skillResult.written.length + skillResult.refreshed.length + skillResult.skipped.length;
101
+ if (skillTotal === 0) {
102
+ console.log(" No bundled skills");
102
103
  }
103
- if (skillResult.skipped.length > 0) {
104
- console.log(` Preserved ${skillResult.skipped.length} existing skill file(s) — delete to refresh`);
105
- }
106
- if (skillResult.written.length === 0 && skillResult.skipped.length === 0) {
107
- console.log(" No skills bundled");
104
+ else {
105
+ if (skillResult.written.length > 0) {
106
+ console.log(` Installed ${skillResult.written.length} skill file(s)`);
107
+ }
108
+ if (skillResult.refreshed.length > 0) {
109
+ console.log(` Refreshed ${skillResult.refreshed.length} skill file(s) to latest`);
110
+ }
111
+ if (skillResult.skipped.length > 0) {
112
+ console.log(` Preserved ${skillResult.skipped.length} existing skill file(s) — customized or unowned`);
113
+ }
108
114
  }
109
115
  // PATH trick: prepend bin dir so agents find emcom/tracker
110
116
  const env = { ...process.env, PATH: `${binDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH}` };
@@ -86,11 +86,17 @@ export function uninstall(opts) {
86
86
  // Remove skills we installed (only the ones that match the shipped bytes —
87
87
  // user-customized files are preserved).
88
88
  const skillResult = uninstallSkills();
89
- if (skillResult.removed.length > 0) {
90
- console.log(` Removed ${skillResult.removed.length} skill file(s)`);
89
+ const skillTotal = skillResult.removed.length + skillResult.preserved.length;
90
+ if (skillTotal === 0) {
91
+ console.log(" No fellow-agents-tracked skill files found");
91
92
  }
92
- if (skillResult.preserved.length > 0) {
93
- console.log(` Preserved ${skillResult.preserved.length} customized skill file(s)`);
93
+ else {
94
+ if (skillResult.removed.length > 0) {
95
+ console.log(` Removed ${skillResult.removed.length} skill file(s)`);
96
+ }
97
+ if (skillResult.preserved.length > 0) {
98
+ console.log(` Preserved ${skillResult.preserved.length} customized/unowned skill file(s)`);
99
+ }
94
100
  }
95
101
  for (const t of targets) {
96
102
  try {
@@ -89,6 +89,52 @@ function killTree(pid) {
89
89
  process.kill(pid);
90
90
  }
91
91
  }
92
+ // Scan a port for any listening PID and kill it. Used as a fallback when our
93
+ // PID files are missing (e.g., user ran clean) but a previous service is still
94
+ // holding the port — produces orphans that block re-start and re-uninstall.
95
+ function killOnPort(port) {
96
+ const killed = [];
97
+ try {
98
+ if (process.platform === "win32") {
99
+ const output = execSync(`netstat -ano -p tcp`, { stdio: ["ignore", "pipe", "ignore"] }).toString();
100
+ const pids = new Set();
101
+ for (const line of output.split("\n")) {
102
+ const trimmed = line.trim();
103
+ // Match "127.0.0.1:8800 ... LISTENING <pid>"
104
+ if (trimmed.includes(`:${port}`) && trimmed.includes("LISTENING")) {
105
+ const parts = trimmed.split(/\s+/);
106
+ const pid = parseInt(parts[parts.length - 1], 10);
107
+ if (!isNaN(pid) && pid > 4)
108
+ pids.add(pid); // skip system PIDs 0/4
109
+ }
110
+ }
111
+ for (const pid of pids) {
112
+ try {
113
+ execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore" });
114
+ killed.push(pid);
115
+ }
116
+ catch { }
117
+ }
118
+ }
119
+ else {
120
+ const output = execSync(`lsof -ti:${port}`, { stdio: ["ignore", "pipe", "ignore"] }).toString();
121
+ for (const line of output.split("\n")) {
122
+ const pid = parseInt(line.trim(), 10);
123
+ if (!isNaN(pid)) {
124
+ try {
125
+ process.kill(pid, "SIGKILL");
126
+ killed.push(pid);
127
+ }
128
+ catch { }
129
+ }
130
+ }
131
+ }
132
+ }
133
+ catch {
134
+ // No output (no process listening) or command failed — return empty
135
+ }
136
+ return killed;
137
+ }
92
138
  export function stopAll() {
93
139
  for (const name of ["emcom-server", "pty-win"]) {
94
140
  const pid = readPid(name);
@@ -106,6 +152,14 @@ export function stopAll() {
106
152
  }
107
153
  removePid(name);
108
154
  }
155
+ // Fallback: scan default ports for orphans whose PID files have been lost.
156
+ // Common after `fellow-agents clean` or manual `Remove-Item` of pid/ dir.
157
+ for (const port of [3700, 8800]) {
158
+ const orphans = killOnPort(port);
159
+ if (orphans.length > 0) {
160
+ console.log(` Killed orphan(s) on :${port} (pid ${orphans.join(", ")})`);
161
+ }
162
+ }
109
163
  }
110
164
  export function waitForHealth(url, timeoutMs = 30000) {
111
165
  const mod = url.startsWith("https") ? https : http;
@@ -1,6 +1,7 @@
1
- import { existsSync, readdirSync, mkdirSync, copyFileSync, readFileSync, statSync, rmSync, rmdirSync } from "fs";
1
+ import { existsSync, readdirSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, statSync, rmSync, rmdirSync } from "fs";
2
2
  import { join } from "path";
3
3
  import { homedir } from "os";
4
+ import { createHash } from "crypto";
4
5
  import { skillsDir } from "./paths.js";
5
6
  // Three target paths per agentskills.io convention — installed CLIs vary; we write to all three
6
7
  // so the skill works regardless of which AI CLI the user is using.
@@ -9,15 +10,24 @@ const targetRoots = [
9
10
  join(homedir(), ".copilot", "skills"), // GitHub Copilot CLI
10
11
  join(homedir(), ".agents", "skills"), // pi + cross-tool universal location
11
12
  ];
13
+ // Sidecar suffix — written next to each shipped file, contains the SHA-256 of
14
+ // the content we shipped. Used to detect "we wrote this, user hasn't touched"
15
+ // vs "user customized" on later installs and uninstall.
16
+ const SIDECAR_SUFFIX = ".fellow-agents-shipped";
12
17
  /**
13
18
  * Copy bundled skills to all known target paths.
14
19
  *
15
- * Strategy: write if absent. Never overwrite existing files (treated as
16
- * user-customized). To force a refresh, the user can delete the target file
17
- * and re-run fellow-agents.
20
+ * Update semantics (v0.0.18+):
21
+ * - Target absent: write file + sidecar.
22
+ * - Target has sidecar AND target SHA matches sidecar: we own it, user hasn't
23
+ * modified — safe to refresh with new content. Write file + update sidecar.
24
+ * - Target has sidecar AND target SHA differs from sidecar: user edited our
25
+ * shipped file. Preserve, don't touch sidecar.
26
+ * - Target exists with NO sidecar: pre-existing file (pre-v0.0.18 install, or
27
+ * user-placed). Preserve — we can't prove we own it.
18
28
  */
19
29
  export function installSkills() {
20
- const result = { written: [], skipped: [] };
30
+ const result = { written: [], refreshed: [], skipped: [] };
21
31
  if (!existsSync(skillsDir))
22
32
  return result;
23
33
  const skillNames = readdirSync(skillsDir).filter((name) => {
@@ -33,16 +43,42 @@ export function installSkills() {
33
43
  const skillFiles = walkSkillFiles(sourceSkillDir);
34
44
  for (const relPath of skillFiles) {
35
45
  const sourceFile = join(sourceSkillDir, relPath);
46
+ const sourceSha = sha256File(sourceFile);
36
47
  for (const root of targetRoots) {
37
48
  const targetFile = join(root, skillName, relPath);
38
- if (existsSync(targetFile)) {
39
- // User-customized or already-installed — never overwrite
49
+ const sidecarFile = targetFile + SIDECAR_SUFFIX;
50
+ if (!existsSync(targetFile)) {
51
+ // First-time install
52
+ mkdirSync(join(root, skillName, ...relPath.split(/[\\/]/).slice(0, -1)), { recursive: true });
53
+ copyFileSync(sourceFile, targetFile);
54
+ writeFileSync(sidecarFile, sourceSha, "utf-8");
55
+ result.written.push(targetFile);
56
+ continue;
57
+ }
58
+ // Target exists — decide based on sidecar
59
+ if (!existsSync(sidecarFile)) {
60
+ // No sidecar → we can't prove we own this file, preserve
40
61
  result.skipped.push(targetFile);
41
62
  continue;
42
63
  }
43
- mkdirSync(join(root, skillName, ...relPath.split(/[\\/]/).slice(0, -1)), { recursive: true });
44
- copyFileSync(sourceFile, targetFile);
45
- result.written.push(targetFile);
64
+ const recordedSha = readFileSync(sidecarFile, "utf-8").trim();
65
+ const currentSha = sha256File(targetFile);
66
+ if (currentSha === recordedSha) {
67
+ // Target matches what we previously shipped → user hasn't touched → safe refresh
68
+ if (currentSha === sourceSha) {
69
+ // Same shipped version, nothing to do
70
+ result.skipped.push(targetFile);
71
+ }
72
+ else {
73
+ copyFileSync(sourceFile, targetFile);
74
+ writeFileSync(sidecarFile, sourceSha, "utf-8");
75
+ result.refreshed.push(targetFile);
76
+ }
77
+ }
78
+ else {
79
+ // User edited the file after we shipped it — preserve
80
+ result.skipped.push(targetFile);
81
+ }
46
82
  }
47
83
  }
48
84
  }
@@ -51,13 +87,15 @@ export function installSkills() {
51
87
  /**
52
88
  * Remove skill files we installed, if the user hasn't modified them.
53
89
  *
54
- * Compares each target file byte-for-byte against the bundled version.
55
- * Matching safe to delete (we wrote it, nothing changed). Differing →
56
- * preserve (user customized or it was already there before we showed up).
90
+ * Sidecar-based ownership detection (v0.0.18+):
91
+ * - Target has sidecar AND target SHA matches sidecar → we own it, user
92
+ * hasn't touched delete file + sidecar.
93
+ * - Target has sidecar AND target SHA differs → user customized → preserve
94
+ * (file and sidecar).
95
+ * - Target has no sidecar → we can't prove we own it → preserve.
57
96
  *
58
97
  * After file removal, attempts to clean up empty skill directories and
59
- * empty target root directories (e.g., ~/.copilot/skills/ if user doesn't
60
- * have Copilot installed).
98
+ * empty target root directories.
61
99
  */
62
100
  export function uninstallSkills() {
63
101
  const result = { removed: [], preserved: [] };
@@ -79,15 +117,21 @@ export function uninstallSkills() {
79
117
  if (!existsSync(targetSkillDir))
80
118
  continue;
81
119
  for (const relPath of skillFiles) {
82
- const sourceFile = join(sourceSkillDir, relPath);
83
120
  const targetFile = join(targetSkillDir, relPath);
121
+ const sidecarFile = targetFile + SIDECAR_SUFFIX;
84
122
  if (!existsSync(targetFile))
85
123
  continue;
124
+ if (!existsSync(sidecarFile)) {
125
+ // No sidecar — can't prove ownership, preserve
126
+ result.preserved.push(targetFile);
127
+ continue;
128
+ }
86
129
  try {
87
- const sourceBytes = readFileSync(sourceFile);
88
- const targetBytes = readFileSync(targetFile);
89
- if (Buffer.compare(sourceBytes, targetBytes) === 0) {
130
+ const recordedSha = readFileSync(sidecarFile, "utf-8").trim();
131
+ const currentSha = sha256File(targetFile);
132
+ if (currentSha === recordedSha) {
90
133
  rmSync(targetFile);
134
+ rmSync(sidecarFile);
91
135
  result.removed.push(targetFile);
92
136
  }
93
137
  else {
@@ -95,18 +139,18 @@ export function uninstallSkills() {
95
139
  }
96
140
  }
97
141
  catch {
98
- // Can't read either file — skip, don't risk data loss
99
142
  result.preserved.push(targetFile);
100
143
  }
101
144
  }
102
- // Try to remove empty skill dir (best effort — won't remove if user has
103
- // their own files in there or any preserved files remain)
104
145
  tryRemoveIfEmpty(targetSkillDir);
105
146
  tryRemoveIfEmpty(root);
106
147
  }
107
148
  }
108
149
  return result;
109
150
  }
151
+ function sha256File(path) {
152
+ return createHash("sha256").update(readFileSync(path)).digest("hex");
153
+ }
110
154
  function tryRemoveIfEmpty(dir) {
111
155
  try {
112
156
  if (existsSync(dir) && readdirSync(dir).length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fellow-agents",
3
- "version": "0.0.16",
3
+ "version": "0.0.19",
4
4
  "description": "Multi-agent system — multiple Claude Code instances collaborating via messaging",
5
5
  "type": "module",
6
6
  "bin": {
@@ -111,11 +111,9 @@ Don't reply blind to in-flight work. A short delay to read the thread first prev
111
111
  emcom <subcommand> [args]
112
112
  ```
113
113
 
114
- If `emcom` is not in PATH, the binary lives at one of:
115
- - `~/.claude/skills/emcom/bin/emcom` (Claude Code skill install)
116
- - `~/.copilot/skills/emcom/bin/emcom` (GitHub Copilot skill install)
117
- - `~/.agents/skills/emcom/bin/emcom` (pi / universal skill install)
118
- - Or whatever PATH was set up by `npm install -g fellow-agents`
114
+ `emcom` is on PATH when fellow-agents has been installed (`npm install -g fellow-agents`) — the npm-created shim wraps `~/.fellow-agents/bin/<platform>/emcom`. **Use the bare command. Do not prepend any skills-directory path.**
115
+
116
+ If a CLI environment can't find `emcom` on PATH, that means fellow-agents isn't installed (or its bin shim hasn't been picked up by the shell). Tell the user to run `npm install -g fellow-agents` rather than guessing at a skill-bundled path — fellow-agents does not ship binaries inside `~/.claude/skills/`, `~/.copilot/skills/`, or `~/.agents/skills/`.
119
117
 
120
118
  **Permission-friendly invocation** (matters in some CLIs that gate command execution):
121
119