@join-pantheon/cli 0.25.3 → 0.26.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.
@@ -1,9 +1,10 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
- import { HOOK_MARKER, MAX_CONSECUTIVE_BLOCKS } from "./notify.js";
4
+ import { MAX_CONSECUTIVE_BLOCKS, isOurHookCommand } from "./notify.js";
5
+ import { copilotHome } from "./agents.js";
5
6
  function ours(cmd) {
6
- return !!cmd?.includes(HOOK_MARKER);
7
+ return isOurHookCommand(cmd);
7
8
  }
8
9
  function readJson(file) {
9
10
  if (!existsSync(file))
@@ -189,4 +190,61 @@ export function removeCursorHooks(projectDir = process.cwd()) {
189
190
  }
190
191
  return out;
191
192
  }
193
+ export function copilotHooksPath(scope, projectDir = process.cwd()) {
194
+ return scope === "user"
195
+ ? join(copilotHome(), "hooks", "pantheon.json")
196
+ : join(projectDir, ".github", "hooks", "pantheon.json");
197
+ }
198
+ function copilotEvents(bin) {
199
+ return {
200
+ agentStop: `${bin} hook copilot-stop`,
201
+ userPromptSubmitted: `${bin} hook prompt`,
202
+ sessionEnd: `${bin} hook end`,
203
+ };
204
+ }
205
+ export function copilotHookState(scope, projectDir = process.cwd()) {
206
+ const file = copilotHooksPath(scope, projectDir);
207
+ const doc = readJson(file);
208
+ if (!doc?.hooks)
209
+ return { agent: "copilot", label: "Copilot", installed: false };
210
+ const events = [];
211
+ for (const [event, entries] of Object.entries(doc.hooks)) {
212
+ if ((entries ?? []).some((h) => ours(h.command) || ours(h.bash)))
213
+ events.push(event);
214
+ }
215
+ return events.length
216
+ ? { agent: "copilot", label: "Copilot", installed: true, scope, file, events }
217
+ : { agent: "copilot", label: "Copilot", installed: false };
218
+ }
219
+ export function installCopilotHooks(bin, scope, projectDir = process.cwd()) {
220
+ const file = copilotHooksPath(scope, projectDir);
221
+ const existed = existsSync(file);
222
+ const events = copilotEvents(bin);
223
+ const doc = { version: 1, hooks: {} };
224
+ for (const [event, cmd] of Object.entries(events)) {
225
+ doc.hooks[event] = [{ type: "command", command: cmd, timeoutSec: 10, timeout: 10 }];
226
+ }
227
+ writeJson(file, doc);
228
+ return [
229
+ ` ${existed ? "updated" : "installed"} ${file}`,
230
+ ` runs ${events.agentStop}`,
231
+ ` ${events.userPromptSubmitted}`,
232
+ ` ${events.sessionEnd}`,
233
+ ` ${""}(read by Copilot CLI and by Copilot in VS Code${scope === "project" ? "; repo-level hooks run once the folder is trusted" : ""})`,
234
+ ];
235
+ }
236
+ export function removeCopilotHooks(projectDir = process.cwd()) {
237
+ const out = [];
238
+ for (const scope of ["project", "user"]) {
239
+ const file = copilotHooksPath(scope, projectDir);
240
+ if (!copilotHookState(scope, projectDir).installed)
241
+ continue;
242
+ try {
243
+ rmSync(file, { force: true });
244
+ out.push(` removed ${file}`);
245
+ }
246
+ catch { }
247
+ }
248
+ return out;
249
+ }
192
250
  //# sourceMappingURL=hooks-install.js.map
package/dist/install.js CHANGED
@@ -1,48 +1,71 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
- import { homedir } from "node:os";
3
+ import { homedir, platform } from "node:os";
4
4
  import { join, dirname, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { grantToolPermissions, claudeSettingsFile, cursorCliConfigFile } from "./grants.js";
7
7
  import { PANTHEON_TOOL_NAMES, WRITE_TOOL_NAMES } from "./tools.js";
8
8
  import { load } from "./config.js";
9
9
  import { loadProfile } from "./profile.js";
10
+ import { agentSpawn, onPathNamed, which } from "./which.js";
10
11
  const HERE = dirname(fileURLToPath(import.meta.url));
11
12
  export function onPath() {
12
- try {
13
- execFileSync("sh", ["-lc", "command -v pantheon"], { stdio: ["ignore", "ignore", "ignore"] });
14
- return true;
15
- }
16
- catch {
17
- return false;
18
- }
13
+ return onPathNamed("pantheon");
19
14
  }
20
- export function launcher(force) {
15
+ export function launcher(force, os = platform()) {
21
16
  const portable = force === "portable" || (force !== "local" && onPath());
17
+ if (os === "win32")
18
+ return { command: process.execPath, args: [resolve(HERE, "cli.js"), "mcp"], portable: false };
22
19
  return portable
23
20
  ? { command: "pantheon", args: ["mcp"], portable: true }
24
21
  : { command: process.execPath, args: [resolve(HERE, "cli.js"), "mcp"], portable: false };
25
22
  }
26
- function mergeMcpJson(file, entry) {
27
- let doc = {};
28
- if (existsSync(file)) {
29
- try {
30
- doc = JSON.parse(readFileSync(file, "utf8"));
31
- }
32
- catch {
33
- throw new Error(`${file} is not valid JSON — fix or move it, then re-run`);
34
- }
23
+ function readConfigJson(file) {
24
+ if (!existsSync(file))
25
+ return {};
26
+ const text = readFileSync(file, "utf8");
27
+ if (!text.trim())
28
+ return {};
29
+ try {
30
+ const doc = JSON.parse(text);
31
+ if (doc && typeof doc === "object" && !Array.isArray(doc))
32
+ return doc;
35
33
  }
34
+ catch { }
35
+ throw new Error(`${file} is not valid JSON — fix or move it, then re-run`);
36
+ }
37
+ function mergeMcpJson(file, entry, shape = {}) {
38
+ const doc = readConfigJson(file);
36
39
  const existed = existsSync(file);
37
40
  doc.mcpServers ??= {};
38
41
  const before = JSON.stringify(doc.mcpServers["pantheon"] ?? null);
39
- doc.mcpServers["pantheon"] = { ...entry, env: {} };
42
+ doc.mcpServers["pantheon"] = { ...shape, ...entry, env: {} };
40
43
  if (before === JSON.stringify(doc.mcpServers["pantheon"]))
41
44
  return "unchanged";
42
45
  mkdirSync(dirname(file), { recursive: true });
43
46
  writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
44
47
  return existed ? "updated" : "created";
45
48
  }
49
+ export const COPILOT_ENTRY_SHAPE = { type: "local", tools: ["*"] };
50
+ export function mergeVsCodeMcpJson(file, entry) {
51
+ const doc = readConfigJson(file);
52
+ const existed = existsSync(file);
53
+ doc.servers ??= {};
54
+ const before = JSON.stringify(doc.servers["pantheon"] ?? null);
55
+ doc.servers["pantheon"] = { type: "stdio", command: entry.command, args: entry.args };
56
+ if (before === JSON.stringify(doc.servers["pantheon"]))
57
+ return "unchanged";
58
+ mkdirSync(dirname(file), { recursive: true });
59
+ writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
60
+ return existed ? "updated" : "created";
61
+ }
62
+ export const copilotMcpConfigFile = (home = homedir(), env = process.env) => join(env.COPILOT_HOME || join(home, ".copilot"), "mcp-config.json");
63
+ export function vsCodeUserDir(home = homedir(), os = platform(), env = process.env) {
64
+ const dir = os === "darwin" ? join(home, "Library", "Application Support", "Code", "User")
65
+ : os === "win32" ? join(env.APPDATA || join(home, "AppData", "Roaming"), "Code", "User")
66
+ : join(env.XDG_CONFIG_HOME || join(home, ".config"), "Code", "User");
67
+ return existsSync(dir) ? dir : null;
68
+ }
46
69
  const BRIEF_MARK = "<!-- pantheon:begin -->";
47
70
  function brief(cfg) {
48
71
  return `${BRIEF_MARK}
@@ -95,14 +118,19 @@ export function install(projectDir, cfg, force) {
95
118
  const out = [];
96
119
  const l = launcher(force);
97
120
  const entry = { command: l.command, args: l.args };
98
- out.push(` .mcp.json ${mergeMcpJson(join(projectDir, ".mcp.json"), entry)} (claude code, conductor)`);
121
+ out.push(` .mcp.json ${mergeMcpJson(join(projectDir, ".mcp.json"), entry)} (claude code, conductor, copilot cli)`);
99
122
  out.push(` .cursor/mcp.json ${mergeMcpJson(join(projectDir, ".cursor", "mcp.json"), entry)} (cursor)`);
123
+ const vscodeDir = join(projectDir, ".vscode");
124
+ out.push(existsSync(vscodeDir) || vsCodeUserDir()
125
+ ? ` .vscode/mcp.json ${mergeVsCodeMcpJson(join(vscodeDir, "mcp.json"), entry)} (copilot in vs code)`
126
+ : ` .vscode/mcp.json ${"skipped".padEnd(9)} no .vscode/ here and no VS Code on this machine (a VS Code user runs pantheon install --user)`);
100
127
  for (const f of ["CLAUDE.md", "AGENTS.md"]) {
101
128
  const p = join(projectDir, f);
102
129
  if (existsSync(p) || f === "AGENTS.md")
103
130
  out.push(` ${f.padEnd(25)} ${upsertBrief(p, cfg)} (the collaboration brief)`);
104
131
  }
105
132
  out.push("");
133
+ out.push(" Copilot asks once to trust this folder before it reads the repo's MCP servers and hooks.");
106
134
  out.push(" Codex reads ~/.codex/config.toml, which `pantheon join` already wrote at user scope.");
107
135
  out.push(" If you need it by hand, this is the block (absolute path so a GUI launch finds it):");
108
136
  out.push("");
@@ -128,9 +156,17 @@ export function install(projectDir, cfg, force) {
128
156
  function localEntry() {
129
157
  return { command: process.execPath, args: [resolve(HERE, "cli.js"), "mcp"] };
130
158
  }
131
- function userEntry() {
159
+ function userEntry(os = platform()) {
160
+ if (os === "win32") {
161
+ const node = process.execPath;
162
+ const entry = resolve(HERE, "cli.js");
163
+ if (existsSync(entry))
164
+ return { command: node, args: [entry, "mcp"] };
165
+ const abs = which("pantheon");
166
+ return abs ? { command: abs, args: ["mcp"] } : { command: "pantheon", args: ["mcp"] };
167
+ }
132
168
  try {
133
- const abs = execFileSync("sh", ["-lc", "command -v pantheon"], { encoding: "utf8" }).trim();
169
+ const abs = which("pantheon") ?? "";
134
170
  if (abs && !isVersionManagedPath(abs))
135
171
  return { command: abs, args: ["mcp"] };
136
172
  if (abs)
@@ -145,18 +181,17 @@ function isVersionManagedPath(p) {
145
181
  }
146
182
  function claudeUserScope(entry) {
147
183
  const tool = "claude code";
148
- try {
149
- execFileSync("sh", ["-lc", "command -v claude"], { stdio: "ignore" });
150
- }
151
- catch {
184
+ const claudeBin = which("claude");
185
+ if (!claudeBin)
152
186
  return { tool, where: "-", status: "skipped (claude not on PATH)" };
153
- }
154
187
  try {
188
+ const remove = agentSpawn(claudeBin, ["mcp", "remove", "pantheon", "-s", "user"]);
189
+ const add = agentSpawn(claudeBin, ["mcp", "add", "pantheon", "-s", "user", "--", entry.command, ...entry.args]);
155
190
  try {
156
- execFileSync("claude", ["mcp", "remove", "pantheon", "-s", "user"], { stdio: "ignore" });
191
+ execFileSync(remove.command, remove.args, { stdio: "ignore", ...remove.options });
157
192
  }
158
193
  catch { }
159
- execFileSync("claude", ["mcp", "add", "pantheon", "-s", "user", "--", entry.command, ...entry.args], { stdio: "ignore" });
194
+ execFileSync(add.command, add.args, { stdio: "ignore", ...add.options });
160
195
  return { tool, where: "user scope", status: "ready in every project and every Conductor window" };
161
196
  }
162
197
  catch (e) {
@@ -172,11 +207,39 @@ function cursorUserScope(entry) {
172
207
  return { tool: "cursor", where: file, status: `failed: ${e.message}` };
173
208
  }
174
209
  }
210
+ function copilotUserScope(entry) {
211
+ const tool = "copilot";
212
+ const file = copilotMcpConfigFile();
213
+ let present = existsSync(dirname(file));
214
+ if (!present && onPathNamed("copilot"))
215
+ present = true;
216
+ if (!present)
217
+ return { tool, where: "-", status: "skipped (copilot not on PATH)" };
218
+ try {
219
+ return { tool, where: file, status: mergeMcpJson(file, entry, COPILOT_ENTRY_SHAPE) };
220
+ }
221
+ catch (e) {
222
+ return { tool, where: file, status: `failed: ${e.message}` };
223
+ }
224
+ }
225
+ function vsCodeUserScope(entry) {
226
+ const tool = "vs code";
227
+ const dir = vsCodeUserDir();
228
+ if (!dir)
229
+ return { tool, where: "-", status: "skipped (VS Code not found)" };
230
+ const file = join(dir, "mcp.json");
231
+ try {
232
+ return { tool, where: file, status: mergeVsCodeMcpJson(file, entry) };
233
+ }
234
+ catch (e) {
235
+ return { tool, where: file, status: `failed: ${e.message}` };
236
+ }
237
+ }
175
238
  export const CODEX_APPROVAL_LINE = `default_tools_approval_mode = "approve"`;
176
239
  export function approvalSummary(approval) {
177
240
  return approval === "ask-before-sending"
178
241
  ? `your agent asks you before each send, goal or claim in your name; reading and waiting run without a prompt. Unattended runs keep their own per-run approvals — this governs the agent you sit in front of`
179
- : `pantheon's ${PANTHEON_TOOL_NAMES.length} tools run without a per-call prompt in Claude Code, Cursor and Codex — your agent coordinates for you without stopping to ask`;
242
+ : `pantheon's ${PANTHEON_TOOL_NAMES.length} tools run without a per-call prompt in Claude Code, Cursor and Codex — your agent coordinates for you without stopping to ask. Copilot has no allow-list file: it asks once per tool per folder and remembers your answer`;
180
243
  }
181
244
  export function codexTomlBlock(entry, env = { PANTHEON_AGENT: "codex" }, approval = "approve") {
182
245
  const envToml = Object.entries(env)
@@ -374,6 +437,7 @@ export function installForUser(approval = chosenApproval()) {
374
437
  const entry = userEntry();
375
438
  return [
376
439
  claudeUserScope(entry), cursorUserScope(entry), codexUserScope(entry, approval),
440
+ copilotUserScope(entry), vsCodeUserScope(entry),
377
441
  ...grantToolPermissions(approval).map((g) => ({
378
442
  tool: g.tool,
379
443
  where: g.tool.startsWith("claude") ? claudeSettingsFile() : cursorCliConfigFile(),
package/dist/machine.js CHANGED
@@ -4,6 +4,7 @@ import { homedir, platform } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { CONFIG_DIR } from "./config.js";
6
6
  import { plistPath } from "./service.js";
7
+ import { copilotMcpConfigFile, vsCodeUserDir } from "./install.js";
7
8
  const sh = (cmd) => {
8
9
  try {
9
10
  return execFileSync("sh", ["-lc", cmd], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
@@ -109,6 +110,16 @@ export function wiring() {
109
110
  tool: "codex",
110
111
  state: existsSync(codex) && /\[mcp_servers\.pantheon\]/.test(readFileSync(codex, "utf8")) ? "wired" : "not wired",
111
112
  });
113
+ const copilot = copilotMcpConfigFile();
114
+ out.push({
115
+ tool: "copilot",
116
+ state: existsSync(copilot) && /"pantheon"/.test(readFileSync(copilot, "utf8")) ? "wired" : "not wired",
117
+ });
118
+ const vscodeDir = vsCodeUserDir();
119
+ if (vscodeDir) {
120
+ const f = join(vscodeDir, "mcp.json");
121
+ out.push({ tool: "vs code", state: existsSync(f) && /"pantheon"/.test(readFileSync(f, "utf8")) ? "wired" : "not wired" });
122
+ }
112
123
  return out;
113
124
  }
114
125
  export function logFileFor(kind, proc) {
package/dist/measure.js CHANGED
@@ -3,7 +3,9 @@ import { existsSync } from "node:fs";
3
3
  import { resolve, dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { claudeGrantedEffective, cursorGrantedEffective, claudeAllow, cursorAllow, isServerWide } from "./grants.js";
6
- import { codexApprovalState } from "./install.js";
6
+ import { codexApprovalState, copilotMcpConfigFile } from "./install.js";
7
+ import { existsSync as fileExists, readFileSync } from "node:fs";
8
+ import { dirname as dirOf } from "node:path";
7
9
  import { PANTHEON_TOOL_NAMES, WRITE_TOOL_NAMES } from "./tools.js";
8
10
  const ROOT = process.env.PANTHEON_MCP_ROOT ?? resolve(dirname(fileURLToPath(import.meta.url)), "..");
9
11
  export const HOST_STARTUP_ALLOWANCE_MS = 10_000;
@@ -157,12 +159,24 @@ export function toolsReport(registered, seen, approval, opts = {}) {
157
159
  problems.push("Codex's per-tool overrides do not match your choice");
158
160
  }
159
161
  }
160
- const detail = `${head}; ${claude.text}, ${cursor.text}, ${codex}`;
162
+ const copilot = seen.copilot === undefined || seen.copilot === null ? null
163
+ : seen.copilot ? "Copilot wired (it asks once per tool per folder and remembers)" : "Copilot has no pantheon entry";
164
+ const detail = `${head}; ${claude.text}, ${cursor.text}, ${codex}${copilot ? `, ${copilot}` : ""}`;
161
165
  if (!problems.length)
162
166
  return { level: "pass", detail };
163
167
  return { level: "warn", detail, fix: `${problems.join("; ")} — pantheon install --user rewrites every grant in your own settings to match this build and your approval choice; a rule in this repo's .claude/settings*.json or .cursor/cli.json is yours to edit by hand` };
164
168
  }
165
169
  export function grantsSeen(projectDir = null) {
166
- return { claude: claudeGrantedEffective(projectDir), cursor: cursorGrantedEffective(projectDir), codex: codexApprovalState() };
170
+ return { claude: claudeGrantedEffective(projectDir), cursor: cursorGrantedEffective(projectDir), codex: codexApprovalState(), copilot: copilotWired() };
171
+ }
172
+ export function copilotWired(file = copilotMcpConfigFile()) {
173
+ if (!fileExists(dirOf(file)))
174
+ return null;
175
+ try {
176
+ return fileExists(file) && /"pantheon"/.test(readFileSync(file, "utf8"));
177
+ }
178
+ catch {
179
+ return false;
180
+ }
167
181
  }
168
182
  //# sourceMappingURL=measure.js.map
package/dist/notice.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { decisionPrompt, decisionTitle } from "./decision-ui.js";
2
- export const TOOL_LABELS = { "claude-code": "Claude Code", codex: "Codex", cursor: "Cursor", grok: "Grok" };
2
+ export const TOOL_LABELS = { "claude-code": "Claude Code", codex: "Codex", cursor: "Cursor", copilot: "Copilot", grok: "Grok" };
3
3
  export const APP_TOOLS = [
4
4
  { id: "cursor-app", label: "Cursor app", kind: "app", bundle: "com.todesktop.230313mzl4w4u92", deeplink: "cursor://anysphere.cursor-deeplink/prompt?text={prompt}" },
5
5
  { id: "claude-app", label: "Claude app · question copied", kind: "app", bundle: "com.anthropic.claudefordesktop" },
6
6
  { id: "chatgpt-app", label: "ChatGPT app · question copied", kind: "app", bundle: "com.openai.chat" },
7
7
  { id: "codex-app", label: "Codex app · question copied", kind: "app", bundle: "com.openai.codex" },
8
+ { id: "vscode-app", label: "VS Code · question copied", kind: "app", bundle: "com.microsoft.VSCode" },
8
9
  ];
9
10
  export const BUDGET = { who: 60, heading: 40, context: 70, body: 190 };
10
11
  const oneLine = (s) => s.replace(/\s+/g, " ").trim();
@@ -64,6 +65,8 @@ const agentLabel = (agent) => {
64
65
  return "Codex";
65
66
  if (a.includes("cursor"))
66
67
  return "Cursor";
68
+ if (a.includes("copilot"))
69
+ return "Copilot";
67
70
  return null;
68
71
  };
69
72
  function whoLine(from, agent, ts, now) {
package/dist/notifier.js CHANGED
@@ -99,6 +99,7 @@ export function installedTools() {
99
99
  "claude-app": ["/Applications/Claude.app", join(homedir(), "Applications", "Claude.app")],
100
100
  "chatgpt-app": ["/Applications/ChatGPT.app", join(homedir(), "Applications", "ChatGPT.app")],
101
101
  "codex-app": ["/Applications/Codex.app", join(homedir(), "Applications", "Codex.app")],
102
+ "vscode-app": ["/Applications/Visual Studio Code.app", join(homedir(), "Applications", "Visual Studio Code.app")],
102
103
  };
103
104
  for (const t of APP_TOOLS)
104
105
  if ((APP_PATHS[t.id] ?? []).some((p) => existsSync(p)))
package/dist/notify.js CHANGED
@@ -3,8 +3,11 @@ import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { whichPantheon } from "./service.js";
5
5
  import { detectForHooks, agentById } from "./agents.js";
6
- import { codexHookState, cursorHookState, installCodexHooks, installCursorHooks, removeCodexHooks, removeCursorHooks, } from "./hooks-install.js";
6
+ import { codexHookState, cursorHookState, copilotHookState, installCodexHooks, installCursorHooks, installCopilotHooks, removeCodexHooks, removeCursorHooks, removeCopilotHooks, } from "./hooks-install.js";
7
7
  export const HOOK_MARKER = "pantheon hook";
8
+ export const HOOK_RE = /pantheon(?:\.cmd|\.exe|\.bat)?"?\s+hook\s/i;
9
+ export const isOurHookCommand = (cmd) => !!cmd && (cmd.includes(HOOK_MARKER) || HOOK_RE.test(cmd));
10
+ export const hookBin = (bin) => (/\s/.test(bin) ? `"${bin}"` : bin);
8
11
  export function settingsPath(scope, projectDir = process.cwd()) {
9
12
  return scope === "user"
10
13
  ? join(homedir(), ".claude", "settings.json")
@@ -25,7 +28,7 @@ function write(file, doc) {
25
28
  writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
26
29
  }
27
30
  function ours(g) {
28
- return g.hooks?.some((h) => h.command?.includes(HOOK_MARKER)) ?? false;
31
+ return g.hooks?.some((h) => isOurHookCommand(h.command)) ?? false;
29
32
  }
30
33
  export function hookState(projectDir = process.cwd(), only) {
31
34
  for (const scope of (only ? [only] : ["project", "user"])) {
@@ -46,7 +49,7 @@ export function hookState(projectDir = process.cwd(), only) {
46
49
  if (!hit)
47
50
  continue;
48
51
  events.push(event);
49
- command ??= hit.hooks.find((h) => h.command.includes(HOOK_MARKER)).command;
52
+ command ??= hit.hooks.find((h) => isOurHookCommand(h.command)).command;
50
53
  }
51
54
  if (events.length)
52
55
  return { installed: true, scope, file, command, events };
@@ -93,6 +96,9 @@ export function agentHookStates(projectDir = process.cwd(), only) {
93
96
  const cursor = cursorHookState(scope, projectDir);
94
97
  if (cursor.installed)
95
98
  out.push(cursor);
99
+ const copilot = copilotHookState(scope, projectDir);
100
+ if (copilot.installed)
101
+ out.push(copilot);
96
102
  }
97
103
  const byAgent = new Map();
98
104
  for (const s of out) {
@@ -116,6 +122,8 @@ export function repairHook(home) {
116
122
  missing.push(id);
117
123
  if (id === "cursor" && !cursorHookState(intent.scope, projectDir).installed)
118
124
  missing.push(id);
125
+ if (id === "copilot" && !copilotHookState(intent.scope, projectDir).installed)
126
+ missing.push(id);
119
127
  }
120
128
  if (!missing.length)
121
129
  return null;
@@ -132,7 +140,10 @@ export function repairHook(home) {
132
140
  function installClaudeHook(scope, projectDir = process.cwd()) {
133
141
  const file = settingsPath(scope, projectDir);
134
142
  const doc = read(file);
135
- const bin = whichPantheon();
143
+ const found = whichPantheon();
144
+ if (!found)
145
+ throw new Error("pantheon binary not found on PATH");
146
+ const bin = hookBin(found);
136
147
  const events = {
137
148
  Stop: `${bin} hook stop`,
138
149
  UserPromptSubmit: `${bin} hook prompt`,
@@ -165,7 +176,7 @@ export function installHooks(scope, projectDir = process.cwd(), only) {
165
176
  ? only.map((id) => agentById(id)).filter((b) => !!b)
166
177
  : available;
167
178
  if (!pick.length) {
168
- throw new Error("no agent found — install claude, codex, or cursor-agent, or open a project that already uses one");
179
+ throw new Error("no agent found — install claude, codex, cursor-agent, or copilot, or open a project that already uses one");
169
180
  }
170
181
  const lines = [];
171
182
  const agents = [];
@@ -183,6 +194,10 @@ export function installHooks(scope, projectDir = process.cwd(), only) {
183
194
  lines.push(...installCursorHooks(bin, scope, projectDir));
184
195
  agents.push("cursor");
185
196
  }
197
+ else if (backend.id === "copilot") {
198
+ lines.push(...installCopilotHooks(bin, scope, projectDir));
199
+ agents.push("copilot");
200
+ }
186
201
  }
187
202
  lines.push(` effect when your agent tries to finish, it checks whether a peer is waiting`, ` on you — and keeps going instead of leaving them hanging.`, ` it also reports when a turn starts and ends, so a worker can tell`, ` an idle editor from one you are actually using.`);
188
203
  return { lines, agents };
@@ -195,6 +210,7 @@ export function removeHook(projectDir = process.cwd()) {
195
210
  out.push(...removeClaudeHook(projectDir));
196
211
  out.push(...removeCodexHooks(projectDir));
197
212
  out.push(...removeCursorHooks(projectDir));
213
+ out.push(...removeCopilotHooks(projectDir));
198
214
  return out.length ? out : [" nothing installed"];
199
215
  }
200
216
  function removeClaudeHook(projectDir = process.cwd()) {
package/dist/persona.js CHANGED
@@ -170,7 +170,7 @@ export const PERSONA_TEMPLATE = `---
170
170
  # wake_by: alice, bob # only these peers' requests are handled by this persona
171
171
  # kinds: ANSWER, ACCEPT # ANSWER (asks), ACCEPT (verdicts), REVISE (objections), CONTINUE (replies/deliveries)
172
172
  # goals: launch-* # only goals whose id or title matches
173
- # agent: auto # claude-code | codex | cursor | auto
173
+ # agent: auto # claude-code | codex | cursor | copilot | auto
174
174
  ---
175
175
  Say how this worker should behave when it is woken while you are away — what it
176
176
  should take on, what it should refuse, and where its judgment ends and yours
package/dist/profile.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { CONFIG_DIR } from "./config.js";
4
- export const AGENT_SURFACES = ["codex", "claude-code", "cursor"];
4
+ export const AGENT_SURFACES = ["codex", "claude-code", "cursor", "copilot"];
5
5
  export const SURFACE_BUNDLE_IDS = {
6
6
  codex: "com.openai.codex",
7
7
  "claude-code": "com.anthropic.claudefordesktop",
8
8
  cursor: "com.todesktop.230313mzl4w4u92",
9
+ copilot: "com.microsoft.VSCode",
9
10
  };
10
11
  export const profilePath = (home = CONFIG_DIR) => join(home, "profile.json");
11
12
  export function isAgentSurface(value) {
@@ -14,6 +15,8 @@ export function isAgentSurface(value) {
14
15
  export function surfaceFromAgent(agent) {
15
16
  if (agent && /cursor/i.test(agent))
16
17
  return "cursor";
18
+ if (agent && /copilot/i.test(agent))
19
+ return "copilot";
17
20
  if (agent && /claude/i.test(agent))
18
21
  return "claude-code";
19
22
  return "codex";
@@ -16,6 +16,7 @@ import { desktopUnavailable, ephemeralWarning, hostEnv } from "./host-env.js";
16
16
  import { existsSync } from "node:fs";
17
17
  import { grant as grantCap, loadPolicy, policyPath, savePolicy } from "./worker-policy.js";
18
18
  import { Relay } from "./relay.js";
19
+ import { which } from "./which.js";
19
20
  const sh = (cmd) => {
20
21
  try {
21
22
  return execFileSync("sh", ["-lc", cmd], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
@@ -126,7 +127,7 @@ export function findReach(port) {
126
127
  if (ts && /^\d+\.\d+\.\d+\.\d+$/.test(ts)) {
127
128
  return { url: `ws://${ts}:${port}`, how: `tailscale (${ts})`, shareable: true };
128
129
  }
129
- if (sh("command -v cloudflared")) {
130
+ if (which("cloudflared")) {
130
131
  return {
131
132
  url: `ws://127.0.0.1:${port}`, how: "localhost", shareable: false,
132
133
  hint: `cloudflared is installed. To let a teammate reach you:\n` +
@@ -1,4 +1,11 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ export const AUTH_RE = /not (?:logged|signed) in|not authenticated|no (?:github )?(?:authentication|credentials?|token) (?:found|available)|login required|please (?:log|sign) in|run .*login|invalid api key|unauthori[sz]ed|authentication (?:failed|required)|\b401\b|expired (?:token|session)/i;
3
+ export const SIGN_IN_FIX = {
4
+ "claude-code": "run `claude`, then /login",
5
+ codex: "run `codex login`",
6
+ cursor: "run `cursor-agent login`",
7
+ copilot: "run `copilot login` (or `gh auth login` — copilot uses gh's sign-in too)",
8
+ };
2
9
  const sh = (dir, ...args) => execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000 }).trim();
3
10
  export const gitProbe = {
4
11
  isRepo: (dir) => { try {
@@ -42,6 +49,14 @@ catch {
42
49
  return fallback;
43
50
  } };
44
51
  export function diagnoseRun(f, probe = gitProbe) {
52
+ if (!f.exited && AUTH_RE.test(f.detail)) {
53
+ const fix = SIGN_IN_FIX[f.agent ?? ""] ?? "sign in to the agent CLI on this machine";
54
+ return {
55
+ cause: "not_signed_in",
56
+ says: `The agent could not start because nobody is signed in to it${f.agent ? ` (${f.agent})` : ""} — ${exitLine(f.detail)}.`,
57
+ fix: `Sign in once and the next run works: ${fix}. Until then every woken run on this machine fails the same way.`,
58
+ };
59
+ }
45
60
  if (!f.exited) {
46
61
  return {
47
62
  cause: "crashed",
package/dist/service.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync, mkdirSync, existsSync, unlinkSync } from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { homedir, platform } from "node:os";
4
+ import { which } from "./which.js";
4
5
  import { dirname, join, resolve } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { launcher } from "./install.js";
@@ -29,6 +30,8 @@ function argvFor(kind, args) {
29
30
  return abs ? [abs, kind, ...args] : [process.execPath, resolve(HERE, "cli.js"), kind, ...args];
30
31
  }
31
32
  export function whichPantheon() {
33
+ if (platform() === "win32")
34
+ return which("pantheon");
32
35
  try {
33
36
  const p = execFileSync("/usr/bin/which", ["pantheon"], { encoding: "utf8" }).trim();
34
37
  return p.startsWith("/") && existsSync(p) ? p : null;
@@ -195,7 +195,7 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
195
195
  : "only people you approve by hand (pantheon admit <name> --may-wake)"}`);
196
196
  io.say(` • Alerts: ${shortAlert(a.notifications).toLowerCase()}`);
197
197
  io.say(` • Approval: ${a.approval === "approve"
198
- ? "your agent sends to teammates in your name without asking each time (Claude Code, Cursor and Codex)"
198
+ ? "your agent sends to teammates in your name without asking each time (Claude Code, Cursor and Codex; Copilot asks once per tool per folder)"
199
199
  : "your agent asks you before each send, goal and claim; reading and waiting never prompt"}`);
200
200
  io.say(` • Decisions: ${a.decides.length ? `you personally decide ${a.decides.join(", ")}` : "none reserved for you — your agent may decide; add an area when one comes up"}`);
201
201
  if (a.wake !== "no" && ctx.project)
package/dist/tray.js CHANGED
@@ -144,7 +144,7 @@ async function trayStatusOne(cfg) {
144
144
  ];
145
145
  const profile = loadProfile(cfg);
146
146
  const surface = profile.agent.primary_surface;
147
- const surfaceName = surface === "claude-code" ? "Claude" : surface === "cursor" ? "Cursor" : "Codex";
147
+ const surfaceName = surface === "claude-code" ? "Claude" : surface === "cursor" ? "Cursor" : surface === "copilot" ? "Copilot" : "Codex";
148
148
  const rawWorkerState = workerState();
149
149
  return {
150
150
  configured: true,
@@ -43,6 +43,8 @@ export function updateStatus(deps) {
43
43
  export function versionLine(version, behind, latest) {
44
44
  if (behind <= 0)
45
45
  return `pantheon ${version}`;
46
+ if (latest && !semverGt(latest, version))
47
+ return `pantheon ${version}`;
46
48
  return latest
47
49
  ? `pantheon ${version} · ${latest} available — run \`pantheon upgrade\``
48
50
  : `pantheon ${version} · ${behind} update${behind === 1 ? "" : "s"} behind — run \`pantheon upgrade\``;