@join-pantheon/cli 0.25.2 → 0.26.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
@@ -404,22 +404,60 @@ with no human; the cap and lock governors under two workers per member.
404
404
 
405
405
  ### Which agent answers
406
406
 
407
- The worker runs whichever agent CLI this machine has — `--agent claude-code|codex|cursor|auto`
408
- (default auto: first one found on PATH). Each backend is driven the way it actually works, with
409
- the same "no shell unless you said so" rule spelled in its own dialect:
407
+ The worker runs whichever agent CLI this machine has — `--agent claude-code|codex|cursor|copilot|auto`.
408
+ With `auto` (the default) the order is: the agent a persona pins, then the one you chose at setup,
409
+ then PATH order — and a candidate that is not signed in is skipped and named in the run log, so a
410
+ run never fails on an expired session when another agent could have answered. If nothing is
411
+ signed in, the first on PATH runs and the alert says exactly that, with the sign-in command.
412
+ `pantheon doctor` shows the same choice (`worker agent`). Each backend is driven the way it
413
+ actually works, with the same "no shell unless you said so" rule spelled in its own dialect:
410
414
 
411
415
  | backend | bash gated (default) | with `--allow-bash` |
412
416
  |---|---|---|
413
417
  | claude-code | `acceptEdits` + the six pantheon tools | `bypassPermissions` |
414
418
  | codex | `--sandbox read-only` (codex has no edits-without-shell mode) | `--sandbox workspace-write` |
415
419
  | cursor | proposes edits, never applies (`-p` without `--force`) | `--force` |
420
+ | copilot | `--allow-tool=pantheon-worker --allow-tool=write` (edits without shell, Copilot's `write` kind) | `--allow-all-tools` |
416
421
 
417
- All three backends are verified with **real live woken runs** — an idle machine answering a
418
- peer's ASK end to end with Claude Code, Cursor, and Codex. Codex quirks the tooling handles for
422
+ Claude Code, Cursor and Codex are verified with **real live woken runs** — an idle machine answering a
423
+ peer's ASK end to end. Codex quirks the tooling handles for
419
424
  you: no per-run approval flag exists in `codex exec`, MCP arrives via `-c` overrides (a worktree
420
425
  `.codex/` is ignored), and codex silently skips a newly installed hook until you approve it with
421
426
  `/hooks`.
422
427
 
428
+ Copilot runs in a `COPILOT_HOME` of its own for the run, outside the checkout. Copilot loads every
429
+ MCP server and hook it finds in its home, so yours would ride along — your pantheon entry as a second
430
+ server in the interactive role, your hooks, your folder approvals. The per-run home carries only the
431
+ run's own server (named `pantheon-worker`; the repo's committed `pantheon` is disabled by name), trusts
432
+ the worktree so `AGENTS.md` is read, and copies your `config.json` (trusted folders merged) and
433
+ `settings.json` across so your sign-in and model choice still apply. Verified against copilot 1.0.86:
434
+ a fresh home signs in through the system credential store, or through `gh` if that is where you are
435
+ signed in.
436
+
437
+ ### Copilot
438
+
439
+ GitHub Copilot is one backend covering two surfaces. **Copilot CLI** reads the repo's `.mcp.json`
440
+ (the same file Claude Code reads) and `~/.copilot/mcp-config.json`; **Copilot in VS Code** reads
441
+ `.vscode/mcp.json` and its own user `mcp.json`, in a different dialect (`servers`, `stdio`), so
442
+ `pantheon install` writes both. The repo-level VS Code file is the one thing here written on
443
+ evidence rather than by default: `.vscode/` is a directory teams curate and commit, so it appears
444
+ only when the repo already has one or the machine running install has VS Code. A VS Code user on a
445
+ repo with neither is covered by `pantheon install --user`, which writes their own profile's
446
+ `mcp.json`. Hooks go in one file both read: `.github/hooks/pantheon.json` in the
447
+ repo, or `~/.copilot/hooks/pantheon.json` for every project — `agentStop` holds a turn open the way
448
+ Codex's Stop does, `userPromptSubmitted` and `sessionEnd` report busy and idle.
449
+
450
+ Two things Copilot does differently. It has no allow-list file for MCP tools: it asks once per tool
451
+ per folder and remembers your answer, so the first session says yes nine times. And it has no status
452
+ command: `pantheon doctor` reports Copilot signed in when a token is in the environment or `gh` is
453
+ signed in, and says "could not tell" otherwise rather than guessing — a run that fails on sign-in is
454
+ named as that, with `copilot login` as the fix.
455
+
456
+ **On a Copilot Business or Enterprise plan**, an organization owner has to switch on the
457
+ "MCP servers in Copilot" policy before any MCP server, pantheon included, loads. Organizations can
458
+ also restrict MCP servers to an approved registry; the server is called `pantheon` and its only job
459
+ is moving messages between your teammates' agents — nothing here reads code or runs commands.
460
+
423
461
  ### Personas — telling the worker who to be
424
462
 
425
463
  A worker with no instructions escalates too much and trusts too much. A **persona** is one
package/dist/agents.js CHANGED
@@ -1,14 +1,33 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { join, dirname } from "node:path";
4
+ import { join, dirname, isAbsolute } from "node:path";
5
5
  import { launcher } from "./install.js";
6
6
  import { PANTHEON_TOOL_NAMES, PANTHEON_TOOLS } from "./tools.js";
7
+ export function launchArgs(agent, prompt, name) {
8
+ if (agent === "claude-code")
9
+ return ["--name", name, prompt];
10
+ if (agent === "copilot")
11
+ return ["-n", name, "-i", prompt];
12
+ return [prompt];
13
+ }
7
14
  export const AGENTS = [
8
15
  { id: "claude-code", label: "Claude Code", binaries: ["claude"] },
9
16
  { id: "codex", label: "Codex", binaries: ["codex"] },
10
17
  { id: "cursor", label: "Cursor", binaries: ["cursor-agent", "agent"] },
18
+ { id: "copilot", label: "Copilot", binaries: ["copilot"] },
11
19
  ];
20
+ export function copilotHome(home = homedir(), env = process.env) {
21
+ return env.COPILOT_HOME || join(home, ".copilot");
22
+ }
23
+ export function hasCopilotExtension(home = homedir()) {
24
+ try {
25
+ return readdirSync(join(home, ".vscode", "extensions")).some((d) => d.startsWith("github.copilot"));
26
+ }
27
+ catch {
28
+ return false;
29
+ }
30
+ }
12
31
  const sh = (cmd) => {
13
32
  try {
14
33
  return execFileSync("sh", ["-lc", cmd], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
@@ -32,6 +51,11 @@ export function detectForHooks(projectDir = process.cwd()) {
32
51
  out.push(backend);
33
52
  continue;
34
53
  }
54
+ if (backend.id === "copilot") {
55
+ if (existsSync(copilotHome()) || hasCopilotExtension())
56
+ out.push(backend);
57
+ continue;
58
+ }
35
59
  const userDir = backend.id === "claude-code" ? join(homedir(), ".claude")
36
60
  : backend.id === "codex" ? join(homedir(), ".codex")
37
61
  : join(homedir(), ".cursor");
@@ -76,21 +100,95 @@ export function resolveBinary(backend) {
76
100
  }
77
101
  return null;
78
102
  }
79
- export function resolveAgent(preferred) {
103
+ export const SIGN_IN_CHECK = {
104
+ "claude-code": { args: ["auth", "status"], fix: "run `claude`, then /login" },
105
+ codex: { args: ["login", "status"], fix: "run `codex login`" },
106
+ cursor: { args: ["status"], fix: "run `cursor-agent login`" },
107
+ copilot: { args: [], fix: "run `copilot login` (or `gh auth login` — copilot uses gh's sign-in too)" },
108
+ };
109
+ export const COPILOT_TOKEN_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
110
+ export function copilotSignedIn(run = runStatus, env = process.env, gh = () => !!cliOnPath("gh")) {
111
+ if (COPILOT_TOKEN_VARS.some((k) => env[k]))
112
+ return { ok: true, detail: "signed in (token in the environment)" };
113
+ if (!gh())
114
+ return { ok: false, unknown: true, detail: "could not tell (copilot has no status command, and gh is not on PATH)" };
115
+ const r = run("gh", ["auth", "status"]);
116
+ if (r.status === 0)
117
+ return { ok: true, detail: "signed in (via gh)" };
118
+ return { ok: false, unknown: true, detail: "could not tell (gh is not signed in; a `copilot login` is not visible from outside)" };
119
+ }
120
+ const runStatus = (binary, args) => {
121
+ try {
122
+ const out = execFileSync(binary, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 8_000, env: { ...process.env, NO_COLOR: "1" } });
123
+ return { status: 0, out };
124
+ }
125
+ catch (e) {
126
+ const err = e;
127
+ if (err.killed)
128
+ return { status: null, out: "timed out" };
129
+ return { status: err.status ?? null, out: `${err.stdout ?? ""}${err.stderr ?? ""}`.trim() || (err.message ?? "") };
130
+ }
131
+ };
132
+ export const AGENT_HOME_DIR = { "claude-code": ".claude", codex: ".codex", cursor: ".cursor", copilot: ".copilot" };
133
+ export function signedIn(backend, binary, run = runStatus, home = homedir()) {
134
+ if (backend.id === "copilot")
135
+ return copilotSignedIn(run);
136
+ if (!existsSync(join(home, AGENT_HOME_DIR[backend.id])))
137
+ return { ok: false, detail: "never signed in on this machine" };
138
+ const check = SIGN_IN_CHECK[backend.id];
139
+ const r = run(binary, check.args);
140
+ if (r.status === null)
141
+ return { ok: false, unknown: true, detail: `could not tell (${(r.out.split("\n")[0] ?? "").slice(0, 60) || "no answer"})` };
142
+ if (backend.id === "claude-code") {
143
+ try {
144
+ const j = JSON.parse(r.out);
145
+ if (typeof j.loggedIn === "boolean")
146
+ return j.loggedIn ? { ok: true, detail: "signed in" } : { ok: false, detail: "not signed in" };
147
+ }
148
+ catch { }
149
+ }
150
+ return r.status === 0 ? { ok: true, detail: "signed in" } : { ok: false, detail: "not signed in" };
151
+ }
152
+ export function chooseAgent(preferred, opts = {}) {
153
+ const available = opts.available ?? detectAvailable();
154
+ const check = opts.check === null ? null : (opts.check ?? ((b, bin) => signedIn(b, bin, undefined, opts.home)));
155
+ const state = (b, bin) => (check ? check(b, bin) : undefined);
80
156
  if (preferred && preferred !== "auto") {
81
157
  const backend = agentById(preferred);
82
158
  if (!backend)
83
- throw new Error(`unknown agent ${preferred} — use claude-code, codex, cursor, or auto`);
84
- const binary = resolveBinary(backend);
85
- if (!binary)
159
+ throw new Error(`unknown agent ${preferred} — use claude-code, codex, cursor, copilot, or auto`);
160
+ const hit = available.find((a) => a.backend.id === backend.id);
161
+ if (!hit)
86
162
  throw new Error(`${backend.label} is not on PATH (looked for ${backend.binaries.join(", ")})`);
87
- return { backend, binary };
163
+ return { backend, binary: hit.binary, signIn: state(backend, hit.binary), because: "pinned", skipped: [] };
164
+ }
165
+ if (!available.length)
166
+ throw new Error("no agent CLI on PATH — install claude, codex, cursor-agent, or copilot");
167
+ const order = [];
168
+ const wanted = opts.prefer ? agentById(opts.prefer) : undefined;
169
+ const chosen = wanted ? available.find((a) => a.backend.id === wanted.id) : undefined;
170
+ if (chosen)
171
+ order.push({ ...chosen, because: "chosen at setup" });
172
+ for (const a of available)
173
+ if (!order.some((o) => o.backend.id === a.backend.id))
174
+ order.push({ ...a, because: available.length === 1 ? "only one on PATH" : "first signed in on PATH" });
175
+ const skipped = [];
176
+ for (const c of order) {
177
+ const s = state(c.backend, c.binary);
178
+ if (!s || s.ok || s.unknown)
179
+ return { backend: c.backend, binary: c.binary, ...(s ? { signIn: s } : {}), because: c.because, skipped };
180
+ skipped.push({ backend: c.backend, why: `${s.detail} — ${SIGN_IN_CHECK[c.backend.id].fix}` });
88
181
  }
89
- const found = detectOnPath();
90
- if (!found)
91
- throw new Error("no agent CLI on PATH — install claude, codex, or cursor-agent");
92
- const binary = resolveBinary(found);
93
- return { backend: found, binary };
182
+ const first = order[0];
183
+ return { backend: first.backend, binary: first.binary, signIn: state(first.backend, first.binary), because: `${first.because} (none signed in)`, skipped };
184
+ }
185
+ export function describeChoice(c) {
186
+ const head = `${c.backend.label}${c.signIn ? ` — ${c.signIn.detail}` : ""} (${c.because})`;
187
+ return c.skipped.length ? `${head}; skipped ${c.skipped.map((s) => `${s.backend.label} — ${s.why}`).join("; ")}` : head;
188
+ }
189
+ export function resolveAgent(preferred) {
190
+ const c = chooseAgent(preferred, { check: null });
191
+ return { backend: c.backend, binary: c.binary };
94
192
  }
95
193
  export { PANTHEON_TOOL_NAMES, PANTHEON_TOOLS } from "./tools.js";
96
194
  export function headlessSpawn(opts) {
@@ -156,6 +254,32 @@ export function headlessSpawn(opts) {
156
254
  env: procEnv,
157
255
  };
158
256
  }
257
+ if (opts.backend.id === "copilot") {
258
+ const home = opts.runHome ? join(opts.runHome, "copilot") : join(opts.worktree, ".pantheon-copilot-home");
259
+ const config = opts.runHome ? readCopilotConfig() : {};
260
+ const trusted = Array.isArray(config.trustedFolders) ? config.trustedFolders.filter((x) => typeof x === "string") : [];
261
+ if (!trusted.includes(opts.worktree))
262
+ trusted.push(opts.worktree);
263
+ const settings = join(copilotHome(), "settings.json");
264
+ return {
265
+ command: opts.binary,
266
+ args: [
267
+ "-p", opts.prompt, "-s",
268
+ "--no-auto-update", "--no-ask-user", "--disable-builtin-mcps",
269
+ "--disable-mcp-server", "pantheon",
270
+ ...(opts.allowBash ? ["--allow-all-tools"] : [`--allow-tool=${COPILOT_WORKER_SERVER}`, "--allow-tool=write"]),
271
+ ],
272
+ env: { ...procEnv, COPILOT_HOME: home },
273
+ writeFiles: [
274
+ {
275
+ path: join(home, "mcp-config.json"),
276
+ contents: JSON.stringify({ mcpServers: { [COPILOT_WORKER_SERVER]: { type: "local", command: l.command, args: l.args, env: fileEnv, tools: ["*"] } } }, null, 2),
277
+ },
278
+ { path: join(home, "config.json"), contents: JSON.stringify({ ...config, trustedFolders: trusted }, null, 2) },
279
+ ],
280
+ ...(opts.runHome && existsSync(settings) ? { copyFiles: [{ from: settings, to: join(home, "settings.json") }] } : {}),
281
+ };
282
+ }
159
283
  return {
160
284
  command: opts.binary,
161
285
  args: [
@@ -182,15 +306,32 @@ export function headlessSpawn(opts) {
182
306
  ],
183
307
  };
184
308
  }
309
+ export const COPILOT_WORKER_SERVER = "pantheon-worker";
310
+ function readCopilotConfig() {
311
+ try {
312
+ const doc = JSON.parse(readFileSync(join(copilotHome(), "config.json"), "utf8"));
313
+ return doc && typeof doc === "object" && !Array.isArray(doc) ? doc : {};
314
+ }
315
+ catch {
316
+ return {};
317
+ }
318
+ }
185
319
  export function tomlInlineTable(kv) {
186
320
  return `{${Object.entries(kv).map(([k, v]) => `${k} = ${JSON.stringify(v)}`).join(", ")}}`;
187
321
  }
188
322
  export function materializeSpawnFiles(worktree, spawn) {
189
323
  for (const f of spawn.writeFiles ?? []) {
190
- const p = join(worktree, f.path);
191
- mkdirSync(dirname(p), { recursive: true });
324
+ const p = isAbsolute(f.path) ? f.path : join(worktree, f.path);
325
+ mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
192
326
  writeFileSync(p, f.contents, { mode: 0o600 });
193
327
  }
328
+ for (const c of spawn.copyFiles ?? []) {
329
+ try {
330
+ mkdirSync(dirname(c.to), { recursive: true, mode: 0o700 });
331
+ copyFileSync(c.from, c.to);
332
+ }
333
+ catch { }
334
+ }
194
335
  }
195
336
  export function backendFromLabel(label) {
196
337
  if (!label)
package/dist/cli.js CHANGED
@@ -427,6 +427,8 @@ function printReady(tools) {
427
427
  oneTime.set("cursor", `cursor enable the "pantheon" MCP server if it asks`);
428
428
  if (/codex/i.test(t.tool))
429
429
  oneTime.set("codex", `codex run /hooks inside codex once to approve the wake-up hook`);
430
+ if (/copilot/i.test(t.tool))
431
+ oneTime.set("copilot", `copilot say yes once when it asks to trust this folder; on a Copilot Business or Enterprise plan an org owner must have the "MCP servers in Copilot" policy on`);
430
432
  }
431
433
  if (oneTime.size) {
432
434
  console.error(`\n${B("Your agents may each ask once")} ${D("(their own security, not ours — one yes each):")}`);
@@ -484,7 +486,7 @@ ${B("control")}
484
486
  pantheon admit <who> let someone in (--may-wake to allow unattended work here)
485
487
  pantheon admit <who> --revoke remove them
486
488
  pantheon worker stay answerable while you are away
487
- (--agent claude-code|codex|cursor|auto, --persona <name>)
489
+ (--agent claude-code|codex|cursor|copilot|auto, --persona <name>)
488
490
  pantheon policy what this machine's worker may do, in one screen (grant/revoke self-upgrade)
489
491
  pantheon persona how a woken worker should behave: list / init <name> / show <name>
490
492
  pantheon notifier alert you when a task requires your decision
@@ -497,7 +499,7 @@ ${B("control")}
497
499
  pantheon approvals decisions waiting on you; show or answer one
498
500
  pantheon stop [relay|worker|notifier|menubar|all] stop it
499
501
  pantheon hook install your agent keeps working when a peer is waiting on it
500
- (auto-detects claude / codex / cursor on this machine)
502
+ (auto-detects claude / codex / cursor / copilot on this machine)
501
503
  pantheon spend woken runs on this machine, and what pantheon cannot see
502
504
  pantheon leave remove what pantheon put on this machine
503
505
  pantheon panic cut off all unattended work on this machine, now
@@ -1314,6 +1316,15 @@ async function main() {
1314
1316
  case "hook": {
1315
1317
  const sub = argv[1] ?? "status";
1316
1318
  const hookCfg = resolveForHook();
1319
+ const sessionOf = (raw) => {
1320
+ try {
1321
+ const j = JSON.parse(raw);
1322
+ return j.session_id ?? j.sessionId ?? j.conversation_id ?? "unknown";
1323
+ }
1324
+ catch {
1325
+ return "unknown";
1326
+ }
1327
+ };
1317
1328
  const reportIdle = async (session, seen) => {
1318
1329
  if (!hookCfg)
1319
1330
  return true;
@@ -1334,11 +1345,7 @@ async function main() {
1334
1345
  raw += chunk;
1335
1346
  }
1336
1347
  catch { }
1337
- let session = "unknown";
1338
- try {
1339
- session = JSON.parse(raw).session_id ?? "unknown";
1340
- }
1341
- catch { }
1348
+ const session = sessionOf(raw);
1342
1349
  try {
1343
1350
  if (hookCfg) {
1344
1351
  const { reportActivity } = await import("./pending.js");
@@ -1366,11 +1373,7 @@ async function main() {
1366
1373
  raw += chunk;
1367
1374
  }
1368
1375
  catch { }
1369
- let session = "unknown";
1370
- try {
1371
- session = JSON.parse(raw).session_id ?? "unknown";
1372
- }
1373
- catch { }
1376
+ const session = sessionOf(raw);
1374
1377
  try {
1375
1378
  const { cursorSeq } = await import("./pending.js");
1376
1379
  await reportIdle(session, cursorSeq(`hook:${session}`));
@@ -1444,22 +1447,18 @@ async function main() {
1444
1447
  process.exit(0);
1445
1448
  }
1446
1449
  }
1447
- if (sub === "codex-stop") {
1450
+ if (sub === "codex-stop" || sub === "copilot-stop") {
1448
1451
  let raw = "";
1449
1452
  try {
1450
1453
  for await (const chunk of process.stdin)
1451
1454
  raw += chunk;
1452
1455
  }
1453
1456
  catch { }
1454
- let stopSession = "unknown";
1455
- try {
1456
- stopSession = JSON.parse(raw).session_id ?? "unknown";
1457
- }
1458
- catch { }
1457
+ const stopSession = sessionOf(raw);
1459
1458
  try {
1460
1459
  const { pending: pendingFn } = await import("./pending.js");
1461
1460
  const { load: loadCfg } = await import("./config.js");
1462
- const d = await decideStop(raw, {
1461
+ const d = await decideStop(JSON.stringify({ session_id: stopSession }), {
1463
1462
  pending: async (session) => (hookCfg ? pendingFn({ session, cfg: hookCfg }) : Promise.resolve(null)),
1464
1463
  ...(loadCfg()?.member ? { me: loadCfg().member } : {}),
1465
1464
  });
@@ -1557,7 +1556,7 @@ async function main() {
1557
1556
  }
1558
1557
  const states = agentHookStates();
1559
1558
  if (!states.length) {
1560
- console.log(`${D("not installed")}\n Install with: ${C("pantheon hook install")} (auto-detects claude / codex / cursor on PATH)`);
1559
+ console.log(`${D("not installed")}\n Install with: ${C("pantheon hook install")} (auto-detects claude / codex / cursor / copilot on PATH)`);
1561
1560
  return;
1562
1561
  }
1563
1562
  for (const s of states) {
@@ -2007,7 +2006,7 @@ async function main() {
2007
2006
  const publicChange = name !== undefined || title !== undefined || responsibilities !== undefined ||
2008
2007
  humanRequired !== undefined || agentCanDecide !== undefined || capabilities !== undefined || keywords !== undefined;
2009
2008
  if (!surface && !publicChange) {
2010
- die("usage: pantheon profile set [--surface codex|claude-code|cursor] [--name name] [--title title] [--responsibilities a,b] [--human-required a,b] [--agent-can-decide a,b] [--keywords 'domain=word|word;domain=word']");
2009
+ die("usage: pantheon profile set [--surface codex|claude-code|cursor|copilot] [--name name] [--title title] [--responsibilities a,b] [--human-required a,b] [--agent-can-decide a,b] [--keywords 'domain=word|word;domain=word']");
2011
2010
  }
2012
2011
  if (flag("space")) {
2013
2012
  const pf = loadAll() ?? die("not configured");
@@ -2196,9 +2195,9 @@ async function main() {
2196
2195
  case "explain": {
2197
2196
  const cfg = need();
2198
2197
  const { readFileSync, rmSync } = await import("node:fs");
2199
- const { pickExplainAgent, decisionPrompt: dp, allDecisionsPrompt: adp } = await import("./decision-ui.js");
2198
+ const { pickExplainAgent, decisionPrompt: dp, allDecisionsPrompt: adp, decisionTitle: dt, sessionName } = await import("./decision-ui.js");
2200
2199
  const { loadProfile } = await import("./profile.js");
2201
- const { detectAvailable } = await import("./agents.js");
2200
+ const { detectAvailable, launchArgs } = await import("./agents.js");
2202
2201
  const CONFIG_DIR = dirname(CONFIG_PATH);
2203
2202
  let id = argv.find((a, i) => i > 0 && !a.startsWith("--"));
2204
2203
  if (argv.includes("--queued")) {
@@ -2316,10 +2315,10 @@ async function main() {
2316
2315
  break;
2317
2316
  if (choice === "1" && agent) {
2318
2317
  rl.close();
2319
- const prompt = o ? dp(o.id) : `Read Pantheon act ${a.id}${goalId ? ` and the thread of goal ${goalId}` : ""}. Explain in plain words what happened and whether anything is owed by me, then give me numbered next steps. Do not act until I choose.`;
2318
+ const prompt = o ? dp(o.id, dt(o.body)) : `Read Pantheon act ${a.id}${goalId ? ` and the thread of goal ${goalId}` : ""}. Explain in plain words what happened and whether anything is owed by me, then give me numbered next steps. Do not act until I choose.`;
2320
2319
  console.error(D(`opening ${agent.id}…`));
2321
2320
  const { spawnSync } = await import("node:child_process");
2322
- spawnSync(agent.binary, [prompt], { stdio: "inherit" });
2321
+ spawnSync(agent.binary, launchArgs(agent.id, prompt, sessionName(prompt)), { stdio: "inherit" });
2323
2322
  return;
2324
2323
  }
2325
2324
  if (choice === "2") {
@@ -2389,10 +2388,10 @@ async function main() {
2389
2388
  if (hud.prompt) {
2390
2389
  const agent = pickExplainAgent(hud.tool ?? p.agent.primary_surface, detectAvailable());
2391
2390
  if (!agent)
2392
- die("no agent CLI found on this machine (claude, cursor-agent, or codex)");
2391
+ die("no agent CLI found on this machine (claude, cursor-agent, codex, or copilot)");
2393
2392
  console.error(D(`opening ${agent.id}…`));
2394
2393
  const { spawnSync } = await import("node:child_process");
2395
- spawnSync(agent.binary, [hud.prompt], { stdio: "inherit" });
2394
+ spawnSync(agent.binary, launchArgs(agent.id, hud.prompt, sessionName(hud.prompt)), { stdio: "inherit" });
2396
2395
  return;
2397
2396
  }
2398
2397
  if (hud.decision)
@@ -2402,21 +2401,20 @@ async function main() {
2402
2401
  await client.connect().catch((e) => die(e.message));
2403
2402
  const waiting = client.obligations.filter((o) => o.kind === "HUMAN" && o.owner === `human:${cfg.member}`);
2404
2403
  client.close();
2405
- const prompt = id
2406
- ? (waiting.some((o) => o.id === id) ? dp(id) : die(`no decision ${id} is waiting on you`))
2407
- : waiting.length === 1 ? dp(waiting[0].id)
2408
- : waiting.length > 1 ? adp(waiting.length)
2409
- : null;
2404
+ const one = id ? waiting.find((o) => o.id === id) : waiting.length === 1 ? waiting[0] : undefined;
2405
+ if (id && !one)
2406
+ die(`no decision ${id} is waiting on you`);
2407
+ const prompt = one ? dp(one.id, dt(one.body)) : waiting.length > 1 ? adp(waiting.length) : null;
2410
2408
  if (!prompt) {
2411
2409
  console.log(D("no decisions waiting on you — nothing to explain"));
2412
2410
  return;
2413
2411
  }
2414
2412
  const agent = pickExplainAgent(loadProfile(cfg).agent.primary_surface, detectAvailable());
2415
2413
  if (!agent)
2416
- die("no agent CLI found on this machine (claude, cursor-agent, or codex)");
2414
+ die("no agent CLI found on this machine (claude, cursor-agent, codex, or copilot)");
2417
2415
  console.error(D(`opening ${agent.id} with the question…`));
2418
2416
  const { spawnSync } = await import("node:child_process");
2419
- spawnSync(agent.binary, [prompt], { stdio: "inherit" });
2417
+ spawnSync(agent.binary, launchArgs(agent.id, prompt, sessionName(prompt)), { stdio: "inherit" });
2420
2418
  return;
2421
2419
  }
2422
2420
  case "approvals": {
package/dist/config.js CHANGED
@@ -28,12 +28,37 @@ export function normalizeOrigin(url) {
28
28
  const p = new URL(u.includes("://") ? u : `ssh://${u}`);
29
29
  const host = p.hostname.toLowerCase();
30
30
  const path = p.pathname.replace(/^\/+/, "").replace(/\.git\/?$/, "").replace(/\/+$/, "").toLowerCase();
31
- return host && path ? `${host}/${path}` : null;
31
+ if (!host || !path)
32
+ return null;
33
+ const [h, q] = sameRepo(host, path);
34
+ return `${h}/${q}`;
32
35
  }
33
36
  catch {
34
37
  return null;
35
38
  }
36
39
  }
40
+ const PUBLIC_HOSTS = new Set(["github.com", "gitlab.com", "bitbucket.org"]);
41
+ function sameRepo(host, path) {
42
+ let m;
43
+ if (host === "ssh.dev.azure.com" || host === "vs-ssh.visualstudio.com") {
44
+ if ((m = /^v3\/([^/]+)\/([^/]+)\/([^/]+)$/.exec(path)))
45
+ return ["dev.azure.com", `${m[1]}/${m[2]}/${m[3]}`];
46
+ }
47
+ else if (host === "dev.azure.com") {
48
+ if ((m = /^([^/]+)\/([^/]+)\/_git\/([^/]+)$/.exec(path)))
49
+ return [host, `${m[1]}/${m[2]}/${m[3]}`];
50
+ }
51
+ else if ((m = /^([^./]+)\.visualstudio\.com$/.exec(host))) {
52
+ const org = m[1];
53
+ if ((m = /^(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/.exec(path)))
54
+ return ["dev.azure.com", `${org}/${m[1]}/${m[2]}`];
55
+ }
56
+ else if (!PUBLIC_HOSTS.has(host)) {
57
+ if ((m = /^(?:[^/]+\/)*scm\/([^/]+\/[^/]+)$/.exec(path)))
58
+ return [host, m[1]];
59
+ }
60
+ return [host, path];
61
+ }
37
62
  const repoCache = new Map();
38
63
  export function repoInfo(dir = process.cwd()) {
39
64
  const key = resolve(dir);
@@ -48,11 +48,21 @@ export function decisionTitle(body, max = 96) {
48
48
  text = text[0].toUpperCase() + text.slice(1);
49
49
  return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`;
50
50
  }
51
- export function decisionPrompt(id) {
52
- return `Check Pantheon decision ${id}. Explain in plain language what happened, why my input is needed, my realistic options, and your recommendation. Do not decide or act for me; wait for my answer.`;
51
+ export function decisionPrompt(id, title) {
52
+ const head = title?.trim() ? `Pantheon decision: ${title.trim()}
53
+
54
+ ` : "";
55
+ return `${head}Check Pantheon decision ${id}. Explain in plain language what happened, why my input is needed, my realistic options, and your recommendation. Do not decide or act for me; wait for my answer.`;
53
56
  }
54
57
  export function allDecisionsPrompt(count) {
55
- return `Check my ${count} Pantheon decision${count === 1 ? "" : "s"}. Explain each one in plain language: what happened, why I am needed, my options, and your recommendation. Prioritize what needs attention first. Do not decide or act for me; wait for my answers.`;
58
+ return `Pantheon: ${count} decision${count === 1 ? "" : "s"} need${count === 1 ? "s" : ""} me
59
+
60
+ Check my ${count} Pantheon decision${count === 1 ? "" : "s"}. Explain each one in plain language: what happened, why I am needed, my options, and your recommendation. Prioritize what needs attention first. Do not decide or act for me; wait for my answers.`;
61
+ }
62
+ export function sessionName(prompt, max = 60) {
63
+ const first = (prompt.split("\n")[0] ?? "").trim().replace(/^Pantheon decision:\s*/i, "Pantheon: ").replace(/^Pantheon:\s*Pantheon:/i, "Pantheon:");
64
+ const named = /^Pantheon\b/i.test(first) ? first : `Pantheon: ${first}`;
65
+ return named.length <= max ? named : named.slice(0, max - 1).trimEnd() + "…";
56
66
  }
57
67
  export function owedPrompt(title, goal) {
58
68
  return `Pantheon: my side owes this — "${title}". Read the thread (pantheon history${goal ? ` --goal ${goal}` : ""}), tell me in plain language what is owed, what is already done, and exactly what you would send to discharge it. Draft the act, then wait for me before sending.`;
package/dist/doctor.js CHANGED
@@ -13,6 +13,7 @@ import { approvalSummary, chosenApproval, onPath } from "./install.js";
13
13
  import { hookState, readIntent, agentHookStates } from "./notify.js";
14
14
  import { loadPersonas } from "./persona.js";
15
15
  import { workerProc, menubarProc, notifierProc } from "./machine.js";
16
+ import { chooseAgent, describeChoice } from "./agents.js";
16
17
  import { VERSION } from "./version.js";
17
18
  import { serviceInstalled } from "./service.js";
18
19
  import { hostEnv } from "./host-env.js";
@@ -134,10 +135,28 @@ export async function doctor(projectDir) {
134
135
  if (branch)
135
136
  add("pass", "git branch", branch);
136
137
  }
137
- const agents = ["claude", "codex", "cursor-agent"].filter((a) => sh(`command -v ${a}`));
138
+ const agents = ["claude", "codex", "cursor-agent", "copilot"].filter((a) => sh(`command -v ${a}`));
138
139
  agents.length
139
140
  ? add("pass", "agent cli", agents.join(", "), undefined, agents.join(", "))
140
- : add("warn", "agent cli", "found none of claude / codex / cursor-agent on PATH", "fine if you only drive agents through a GUI (Conductor, Cursor), but `pantheon worker` needs a CLI to spawn", "none of claude / codex / cursor-agent on PATH");
141
+ : add("warn", "agent cli", "found none of claude / codex / cursor-agent / copilot on PATH", "fine if you only drive agents through a GUI (Conductor, Cursor, VS Code), but `pantheon worker` needs a CLI to spawn", "none of claude / codex / cursor-agent / copilot on PATH");
142
+ if (agents.length) {
143
+ try {
144
+ const choice = chooseAgent("auto", { prefer: prof.agent.primary_surface });
145
+ const line = describeChoice(choice);
146
+ const bad = choice.signIn && !choice.signIn.ok && !choice.signIn.unknown;
147
+ const fellBack = !!prof.agent.primary_surface && choice.backend.id !== prof.agent.primary_surface && agents.length > 0;
148
+ const shareLine = `${choice.backend.label} — ${choice.signIn?.detail ?? "not checked"}${choice.skipped.length ? `; ${choice.skipped.length} skipped (not signed in)` : ""}`;
149
+ if (bad)
150
+ add("warn", "worker agent", line, `nothing is signed in, so every woken run here fails until you sign in: ${choice.skipped.map((x) => x.why).join("; ") || "sign in to the agent CLI"}`, shareLine);
151
+ else if (fellBack)
152
+ add("warn", "worker agent", line, `you chose ${prof.agent.primary_surface} at setup; runs use ${choice.backend.label} until it is signed in again`, shareLine);
153
+ else
154
+ add("pass", "worker agent", line, undefined, shareLine);
155
+ }
156
+ catch (e) {
157
+ add("warn", "worker agent", e.message, "pantheon setup --agent <claude-code|codex|cursor|copilot> names one that is installed", "could not be chosen");
158
+ }
159
+ }
141
160
  const budget = instructionBudget(cfg);
142
161
  const budgetLine = `${budget.chars} chars (~${budget.tokens} tokens) of session instructions per turn; the ${budget.protocolChars}-char protocol arrives with the first tool call instead`;
143
162
  add(budget.chars > 1_200 ? "warn" : "pass", "mcp context", budgetLine, budget.chars > 1_200 ? "this used to be ~3,800 chars on every turn, and it competed with the work people asked for" : undefined, budgetLine);
@@ -259,7 +278,7 @@ export async function doctor(projectDir) {
259
278
  }
260
279
  }
261
280
  if (!hookStates.length && !hook.installed) {
262
- add("warn", "wake-up hook", "not installed — your agent will not notice a peer's message until you ask it to", "pantheon hook install --user (auto-detects claude / codex / cursor on PATH)", "not installed");
281
+ add("warn", "wake-up hook", "not installed — your agent will not notice a peer's message until you ask it to", "pantheon hook install --user (auto-detects claude / codex / cursor / copilot on PATH)", "not installed");
263
282
  }
264
283
  else if (!reportsActivity) {
265
284
  add("warn", "wake-up hook", `installed (${hook.events?.join(", ")}) but predates the activity hook, so a worker cannot tell your idle editor from one you are using`, "re-run `pantheon hook install --user` to add the UserPromptSubmit half", `installed (${hook.events?.join(", ")}), predates the activity hook`);
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from "node
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { HOOK_MARKER, MAX_CONSECUTIVE_BLOCKS } from "./notify.js";
5
+ import { copilotHome } from "./agents.js";
5
6
  function ours(cmd) {
6
7
  return !!cmd?.includes(HOOK_MARKER);
7
8
  }
@@ -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