pair-mcp 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +70 -2
- package/dist/server/server/src/files.js +86 -0
- package/dist/server/server/src/hub.js +381 -0
- package/dist/server/server/src/index.js +378 -0
- package/dist/server/server/src/kokoro.js +157 -0
- package/dist/server/server/src/script.js +198 -0
- package/dist/server/server/src/sessions.js +105 -0
- package/dist/server/server/src/share.js +357 -0
- package/dist/server/server/src/symbols.js +146 -0
- package/dist/server/shared/protocol.js +2 -0
- package/dist/web/assets/abap-08VXUWAP.js +1 -0
- package/dist/web/assets/apex-BWPQTe0t.js +1 -0
- package/dist/web/assets/azcli-Bc_sGQ0U.js +1 -0
- package/dist/web/assets/bat-i0X4ZdIN.js +1 -0
- package/dist/web/assets/bicep-B5-_aFwp.js +2 -0
- package/dist/web/assets/cameligo-DMUM7wLl.js +1 -0
- package/dist/web/assets/clojure-Cm7r79vr.js +1 -0
- package/dist/web/assets/codicon-DF1abBS2.ttf +0 -0
- package/dist/web/assets/coffee-Ba7i2nA0.js +1 -0
- package/dist/web/assets/cpp-C7h46wYY.js +1 -0
- package/dist/web/assets/csharp-BKxtCVv1.js +1 -0
- package/dist/web/assets/csp-bTuwJoIa.js +1 -0
- package/dist/web/assets/css-DIMkf-bt.js +3 -0
- package/dist/web/assets/css.worker-DwfUZ3fB.js +96 -0
- package/dist/web/assets/cssMode-BI08dtvs.js +1 -0
- package/dist/web/assets/cypher-CVaqCwHa.js +1 -0
- package/dist/web/assets/dart-onAF5SnQ.js +1 -0
- package/dist/web/assets/dockerfile-DZFCIeNp.js +1 -0
- package/dist/web/assets/ecl-94qehhXe.js +1 -0
- package/dist/web/assets/editor.worker-CRz__AKW.js +33 -0
- package/dist/web/assets/elixir-6RTg0lbw.js +1 -0
- package/dist/web/assets/flow9-C5_-GSwl.js +1 -0
- package/dist/web/assets/freemarker2-SkMIhuZh.js +3 -0
- package/dist/web/assets/fsharp-C8Ef5oNN.js +1 -0
- package/dist/web/assets/go-C-y9NEjX.js +1 -0
- package/dist/web/assets/graphql-fmXr3nnJ.js +1 -0
- package/dist/web/assets/handlebars-DYcNx8AM.js +1 -0
- package/dist/web/assets/hcl-CpzslTdj.js +1 -0
- package/dist/web/assets/html-CcrgHtTE.js +1 -0
- package/dist/web/assets/html.worker-D9ugpX4y.js +509 -0
- package/dist/web/assets/htmlMode-5oIzUfaC.js +1 -0
- package/dist/web/assets/index-DEgEEy9I.css +1 -0
- package/dist/web/assets/index-KNiiuC8K.js +971 -0
- package/dist/web/assets/ini-sBoK_t0W.js +1 -0
- package/dist/web/assets/java-BEtHBSE6.js +1 -0
- package/dist/web/assets/javascript-C7q5D4eM.js +1 -0
- package/dist/web/assets/json.worker-CV9fqkUp.js +65 -0
- package/dist/web/assets/jsonMode-SY7IXKUb.js +7 -0
- package/dist/web/assets/julia-Bri6UV-V.js +1 -0
- package/dist/web/assets/kotlin-BOotOW0E.js +1 -0
- package/dist/web/assets/less-B9JPFI3C.js +2 -0
- package/dist/web/assets/lexon-CfSJPG6W.js +1 -0
- package/dist/web/assets/liquid-DGUzV5oa.js +1 -0
- package/dist/web/assets/lspLanguageFeatures-CLwbxE0h.js +4 -0
- package/dist/web/assets/lua-CsQS60Ue.js +1 -0
- package/dist/web/assets/m3-D-oSqn_W.js +1 -0
- package/dist/web/assets/markdown-Cimd5fb3.js +1 -0
- package/dist/web/assets/mdx-BRmXMf1D.js +1 -0
- package/dist/web/assets/mips-CIPQ_RoX.js +1 -0
- package/dist/web/assets/msdax-DauUninz.js +1 -0
- package/dist/web/assets/mysql-SOo6toE5.js +1 -0
- package/dist/web/assets/objective-c-FvmIjYaQ.js +1 -0
- package/dist/web/assets/pascal-DrH0SRf2.js +1 -0
- package/dist/web/assets/pascaligo-D-ptJ9y-.js +1 -0
- package/dist/web/assets/perl-oz_6vUea.js +1 -0
- package/dist/web/assets/pgsql-DTj74zXo.js +1 -0
- package/dist/web/assets/php-nr791fC2.js +1 -0
- package/dist/web/assets/pla-CopQ2nXW.js +1 -0
- package/dist/web/assets/postiats-43DmfD33.js +1 -0
- package/dist/web/assets/powerquery-D3hlyOfw.js +1 -0
- package/dist/web/assets/powershell-DmHpPYUd.js +1 -0
- package/dist/web/assets/protobuf-C531GsRP.js +2 -0
- package/dist/web/assets/pug-Z5eAx3Zn.js +1 -0
- package/dist/web/assets/python-BsgvefrN.js +1 -0
- package/dist/web/assets/qsharp-DkqhCAOL.js +1 -0
- package/dist/web/assets/r-BwWrilGY.js +1 -0
- package/dist/web/assets/razor-v3Nvgj9N.js +1 -0
- package/dist/web/assets/redis-ClamHrr6.js +1 -0
- package/dist/web/assets/redshift-DT7zqm-g.js +1 -0
- package/dist/web/assets/restructuredtext-BYgofb2h.js +1 -0
- package/dist/web/assets/ruby-DezsRK8O.js +1 -0
- package/dist/web/assets/rust-DdL9SqIa.js +1 -0
- package/dist/web/assets/sb-CcwsVR0C.js +1 -0
- package/dist/web/assets/scala-DHpiXF5c.js +1 -0
- package/dist/web/assets/scheme-BeGwcela.js +1 -0
- package/dist/web/assets/scss-gp-XZpBa.js +3 -0
- package/dist/web/assets/shell-CC2rA5mh.js +1 -0
- package/dist/web/assets/solidity-BEEn4gHE.js +1 -0
- package/dist/web/assets/sophia-CRfGWb83.js +1 -0
- package/dist/web/assets/sparql-D_Lu-MrJ.js +1 -0
- package/dist/web/assets/sql-NEE52Syq.js +1 -0
- package/dist/web/assets/st-DbInun42.js +1 -0
- package/dist/web/assets/swift-Bxkupp3x.js +1 -0
- package/dist/web/assets/systemverilog-Bz4Y3fRF.js +1 -0
- package/dist/web/assets/tcl-DISqw1ZD.js +1 -0
- package/dist/web/assets/ts.worker-DIAQkoB3.js +67741 -0
- package/dist/web/assets/tsMode-DnuRvFpJ.js +11 -0
- package/dist/web/assets/twig-De2hgUGE.js +1 -0
- package/dist/web/assets/typescript-DVBBqvQb.js +1 -0
- package/dist/web/assets/typespec-B8J7ngcE.js +1 -0
- package/dist/web/assets/vb-DV3o63ZY.js +1 -0
- package/dist/web/assets/wgsl-DpFanUEy.js +298 -0
- package/dist/web/assets/workers-CMjTx9MP.js +1 -0
- package/dist/web/assets/xml-BmcQF52S.js +1 -0
- package/dist/web/assets/yaml-Ch_LDJmI.js +1 -0
- package/dist/web/favicon.svg +9 -0
- package/dist/web/index.html +14 -0
- package/install.sh +238 -0
- package/package.json +67 -4
- package/scripts/config-edit.mjs +55 -0
- package/skills/code-walkthrough/SKILL.md +100 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 norton120
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,71 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Pair
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
_"Walk me through how our caching works across regions"_
|
|
4
|
+
|
|
5
|
+
_"Why are there two User models?"_
|
|
6
|
+
|
|
7
|
+
_"Let's really dig into how thumbprint extraction works"_
|
|
8
|
+
|
|
9
|
+
Pair is an MCP that allows agents to walk you through code the way a human would - by scrolling and highlighting in the codebase as they explain answers to your questions.
|
|
10
|
+
|
|
11
|
+
The agent controls the cursor and can navigate the file explorer and code editor as it narrates.
|
|
12
|
+
Interrupt any time to divert the conversation, then return to the original answer with a click.
|
|
13
|
+
|
|
14
|
+
>[!WARNING]
|
|
15
|
+
> Make sure you allow sound or this video won't make any sense!
|
|
16
|
+
|
|
17
|
+
https://github.com/user-attachments/assets/d4865547-c69d-45f3-8a83-efeb903307b4
|
|
18
|
+
|
|
19
|
+
>[!NOTE]
|
|
20
|
+
> Some notes on the video above:
|
|
21
|
+
> - the cursor, highlights, and scrolling are the agent, not the user (they are the navigator)
|
|
22
|
+
> - the narration stops when you start typing a question (I interrupt to ask about envars)
|
|
23
|
+
> - clicking the "go back to X" button interrupts and returns to the original narration
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
Uses your agent harness and then either browser native voice or (optionally) Kokoro as a really good local voice model.
|
|
27
|
+
|
|
28
|
+
The first time you select Kokoro expect a delay (5-10 min on most connections) while it initially downloads the model. Future use will be near-instant.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
Requires Node.js ≥ 22.12. The installer is written for macOS (elsewhere it warns and carries on).
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
npm install -g pair-mcp
|
|
35
|
+
pair install # or: pair install claude | codex | opencode
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
To update, run `npm install -g pair-mcp` again. Harness configs point at the global install, so there's nothing to re-run (`npx` won't work for this, since its cache can be cleared).
|
|
39
|
+
|
|
40
|
+
Or from source:
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
git clone https://github.com/pirate-baby/pair.git
|
|
44
|
+
cd pair && ./install.sh # or ./install.sh claude | codex | opencode
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Either way, Pair is installed into each of Claude Code, Codex, and opencode that's on your PATH (missing ones are skipped):
|
|
48
|
+
- **Claude Code:** registers the `pair` MCP server (user scope) and sets `MCP_TOOL_TIMEOUT` in `~/.claude/settings.json`, if not already set, so long walkthroughs don't time out
|
|
49
|
+
- **Codex:** adds `[mcp_servers.pair]` to `~/.codex/config.toml`
|
|
50
|
+
- **opencode:** adds `mcp.pair` to `~/.config/opencode/opencode.json(c)`
|
|
51
|
+
- symlinks the `code-walkthrough` skill into each harness's skills directory
|
|
52
|
+
|
|
53
|
+
Each config file is copied to `<file>.pair-backup` before its first edit. Re-running is safe, and (from source) is how you update after a `git pull`.
|
|
54
|
+
|
|
55
|
+
While a session is running, Pair serves its UI on `http://localhost:7420` (bound to 127.0.0.1 only). Kokoro is opt-in: the first time you pick it, Pair installs `kokoro-js` into `~/.cache/pair/kokoro` and downloads the ~330 MB model from Hugging Face.
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
In your harness of choice, tell the agent to walk you through or explain some part of your repo. Pair takes it from there.
|
|
59
|
+
|
|
60
|
+
### Sharing walkthroughs
|
|
61
|
+
After a walkthrough plays, ask the agent to "share this walkthrough on the PR". It posts a comment on the current branch's pull request with the [GitHub CLI](https://cli.github.com) (run `gh auth login` first). The comment holds a collapsed transcript, for anyone without Pair, and the script itself. Nothing is added to your codebase, and only people who can see the repo can see it.
|
|
62
|
+
|
|
63
|
+
To watch one, click **▶ Play in pair** in the comment. That opens Pair's local UI (http://localhost:7420), so it only works while a Pair session is open in your agent. Or ask your agent to "play the walkthrough on PR 123". If the link opens a session for a different repo, or one whose agent is busy elsewhere, Pair lists your other running sessions and their state, so you can open it in the right one. Either way, it plays exactly as written, without regenerating it, and you can still interrupt with questions. Walkthroughs point at symbols and code snippets rather than line numbers, so they're checked against your checkout. If you're on a different commit, Pair tells you which files have changed since the walkthrough was written.
|
|
64
|
+
|
|
65
|
+
No PR? Ask for a gist instead. Secret gists are unlisted, but anyone with the link can read them.
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
const exec = promisify(execFile);
|
|
6
|
+
const IGNORED = new Set([".git", "node_modules", "dist", "build", ".next", ".venv", "venv", "__pycache__", "target", ".DS_Store", ".idea", ".turbo", "coverage"]);
|
|
7
|
+
const MAX_FILE_BYTES = 2_000_000;
|
|
8
|
+
/** Read-only view of the repository the walkthrough is about. */
|
|
9
|
+
export class Workspace {
|
|
10
|
+
root;
|
|
11
|
+
gitFiles = null;
|
|
12
|
+
gitFilesAt = 0;
|
|
13
|
+
constructor(root) {
|
|
14
|
+
this.root = root;
|
|
15
|
+
}
|
|
16
|
+
setRoot(root) {
|
|
17
|
+
this.root = root;
|
|
18
|
+
this.gitFiles = null;
|
|
19
|
+
}
|
|
20
|
+
get name() {
|
|
21
|
+
return path.basename(this.root);
|
|
22
|
+
}
|
|
23
|
+
/** Normalizes a user/agent-supplied path to a root-relative POSIX path, rejecting escapes. */
|
|
24
|
+
rel(p) {
|
|
25
|
+
const abs = path.isAbsolute(p) && p.startsWith(this.root + path.sep) ? p : path.resolve(this.root, p.replace(/^\/+/, ""));
|
|
26
|
+
const rel = path.relative(this.root, abs);
|
|
27
|
+
if (rel.startsWith("..") || path.isAbsolute(rel))
|
|
28
|
+
throw new Error(`Path escapes the workspace: ${p}`);
|
|
29
|
+
return rel.split(path.sep).join("/");
|
|
30
|
+
}
|
|
31
|
+
async exists(rel) {
|
|
32
|
+
try {
|
|
33
|
+
const st = await fs.stat(path.join(this.root, rel));
|
|
34
|
+
return st.isDirectory() ? "dir" : st.isFile() ? "file" : null;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async read(rel) {
|
|
41
|
+
const abs = path.join(this.root, this.rel(rel));
|
|
42
|
+
const st = await fs.stat(abs);
|
|
43
|
+
if (st.size > MAX_FILE_BYTES)
|
|
44
|
+
throw new Error(`File too large to display: ${rel}`);
|
|
45
|
+
return fs.readFile(abs, "utf8");
|
|
46
|
+
}
|
|
47
|
+
async list(dir) {
|
|
48
|
+
const relDir = dir ? this.rel(dir) : "";
|
|
49
|
+
const files = await this.trackedFiles();
|
|
50
|
+
let entries;
|
|
51
|
+
if (files) {
|
|
52
|
+
const prefix = relDir ? relDir + "/" : "";
|
|
53
|
+
const seen = new Map();
|
|
54
|
+
for (const f of files) {
|
|
55
|
+
if (!f.startsWith(prefix))
|
|
56
|
+
continue;
|
|
57
|
+
const rest = f.slice(prefix.length);
|
|
58
|
+
const slash = rest.indexOf("/");
|
|
59
|
+
const name = slash === -1 ? rest : rest.slice(0, slash);
|
|
60
|
+
if (!seen.has(name))
|
|
61
|
+
seen.set(name, { name, path: prefix + name, dir: slash !== -1 });
|
|
62
|
+
}
|
|
63
|
+
entries = [...seen.values()];
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const dirents = await fs.readdir(path.join(this.root, relDir), { withFileTypes: true });
|
|
67
|
+
entries = dirents
|
|
68
|
+
.filter((d) => !IGNORED.has(d.name))
|
|
69
|
+
.map((d) => ({ name: d.name, path: relDir ? `${relDir}/${d.name}` : d.name, dir: d.isDirectory() }));
|
|
70
|
+
}
|
|
71
|
+
return entries.sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1));
|
|
72
|
+
}
|
|
73
|
+
/** Git-visible files (tracked + untracked, minus ignored), or null outside a git repo. */
|
|
74
|
+
trackedFiles() {
|
|
75
|
+
if (!this.gitFiles || Date.now() - this.gitFilesAt > 10_000) {
|
|
76
|
+
this.gitFilesAt = Date.now();
|
|
77
|
+
this.gitFiles = exec("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
|
|
78
|
+
cwd: this.root,
|
|
79
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
80
|
+
})
|
|
81
|
+
.then(({ stdout }) => stdout.split("\0").filter(Boolean))
|
|
82
|
+
.catch(() => null);
|
|
83
|
+
}
|
|
84
|
+
return this.gitFiles;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { WebSocketServer } from "ws";
|
|
7
|
+
import { Kokoro } from "./kokoro.js";
|
|
8
|
+
import { SessionRegistry } from "./sessions.js";
|
|
9
|
+
const WEB_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../web");
|
|
10
|
+
const MIME = {
|
|
11
|
+
".html": "text/html; charset=utf-8",
|
|
12
|
+
".js": "text/javascript",
|
|
13
|
+
".css": "text/css",
|
|
14
|
+
".ttf": "font/ttf",
|
|
15
|
+
".svg": "image/svg+xml",
|
|
16
|
+
".json": "application/json",
|
|
17
|
+
};
|
|
18
|
+
const log = (...args) => console.error("[pair]", ...args);
|
|
19
|
+
/** How long "Thinking…" may show after the user answers before we assume the agent has gone quiet. */
|
|
20
|
+
const AGENT_SILENCE_MS = Number(process.env.PAIR_AGENT_SILENCE_MS) || 90_000;
|
|
21
|
+
/**
|
|
22
|
+
* Owns the browser UI: serves it, tracks connected tabs, and turns the
|
|
23
|
+
* asynchronous player events into the blocking outcomes MCP tools return.
|
|
24
|
+
*/
|
|
25
|
+
export class Hub {
|
|
26
|
+
ws;
|
|
27
|
+
url = "";
|
|
28
|
+
clients = new Set();
|
|
29
|
+
status = null;
|
|
30
|
+
current = null;
|
|
31
|
+
main = null;
|
|
32
|
+
awaitPrompt = null;
|
|
33
|
+
waiter = null;
|
|
34
|
+
/** An answer that arrived while no tool call was waiting; handed to the next await_user. */
|
|
35
|
+
queued = null;
|
|
36
|
+
connectWaiters = [];
|
|
37
|
+
kokoro = new Kokoro();
|
|
38
|
+
ended = null;
|
|
39
|
+
silenceTimer = null;
|
|
40
|
+
shared = null;
|
|
41
|
+
sessions = new SessionRegistry();
|
|
42
|
+
/** Shown in the session picker, e.g. "Claude Code". */
|
|
43
|
+
harness = "Unknown agent";
|
|
44
|
+
constructor(ws) {
|
|
45
|
+
this.ws = ws;
|
|
46
|
+
this.kokoro.onStatus = (status) => this.broadcast({ type: "kokoro", status });
|
|
47
|
+
}
|
|
48
|
+
async listen(preferredPort) {
|
|
49
|
+
const server = http.createServer((req, res) => this.handle(req, res).catch((e) => {
|
|
50
|
+
res.writeHead(500, { "content-type": "text/plain" }).end(String(e.message));
|
|
51
|
+
}));
|
|
52
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
53
|
+
server.on("upgrade", (req, socket, head) => {
|
|
54
|
+
if (req.url !== "/ws" || !fromLocalPage(req))
|
|
55
|
+
return socket.destroy();
|
|
56
|
+
wss.handleUpgrade(req, socket, head, (sock) => this.connect(sock));
|
|
57
|
+
});
|
|
58
|
+
for (let port = preferredPort; port < preferredPort + 20; port++) {
|
|
59
|
+
const ok = await new Promise((resolve) => {
|
|
60
|
+
server.once("error", () => resolve(false));
|
|
61
|
+
server.listen(port, "127.0.0.1", () => resolve(true));
|
|
62
|
+
});
|
|
63
|
+
if (ok) {
|
|
64
|
+
this.url = `http://localhost:${port}`;
|
|
65
|
+
log(`UI at ${this.url} (root: ${this.ws.root})`);
|
|
66
|
+
await this.sessions.start(this.url, this.ws.root, this.harness).catch((e) => log("session registry:", e));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`No free port in ${preferredPort}-${preferredPort + 19}`);
|
|
71
|
+
}
|
|
72
|
+
/** Opens a browser tab if none is connected, and waits for it. */
|
|
73
|
+
async ensureOpen() {
|
|
74
|
+
if (this.clients.size)
|
|
75
|
+
return true;
|
|
76
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
|
|
77
|
+
if (!process.env.PAIR_NO_OPEN)
|
|
78
|
+
spawn(cmd, [this.url], { stdio: "ignore", detached: true }).on("error", () => { }).unref();
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const t = setTimeout(() => resolve(false), process.env.PAIR_NO_OPEN ? 120_000 : 20_000);
|
|
81
|
+
this.connectWaiters.push(() => (clearTimeout(t), resolve(true)));
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
setStatus(text) {
|
|
85
|
+
this.status = text;
|
|
86
|
+
this.broadcast({ type: "status", text });
|
|
87
|
+
}
|
|
88
|
+
/** Called on every agent tool call: the agent is still engaged. */
|
|
89
|
+
touch() {
|
|
90
|
+
if (this.silenceTimer)
|
|
91
|
+
clearTimeout(this.silenceTimer);
|
|
92
|
+
this.silenceTimer = null;
|
|
93
|
+
this.ended = null;
|
|
94
|
+
}
|
|
95
|
+
/** After the user answers, the agent should do *something*. If it never does, don't spin forever. */
|
|
96
|
+
expectAgent() {
|
|
97
|
+
if (this.silenceTimer)
|
|
98
|
+
clearTimeout(this.silenceTimer);
|
|
99
|
+
this.silenceTimer = setTimeout(() => {
|
|
100
|
+
this.silenceTimer = null;
|
|
101
|
+
this.setStatus("No reply from the agent. It may have finished, so continue in its chat, or ask again here.");
|
|
102
|
+
}, AGENT_SILENCE_MS);
|
|
103
|
+
this.silenceTimer.unref();
|
|
104
|
+
}
|
|
105
|
+
/** Ends the session: the UI says goodbye (if given) and stops taking questions. */
|
|
106
|
+
endSession(message) {
|
|
107
|
+
this.touch();
|
|
108
|
+
this.current = this.main = null;
|
|
109
|
+
this.awaitPrompt = null;
|
|
110
|
+
this.queued = null;
|
|
111
|
+
this.status = null;
|
|
112
|
+
this.ended = { message };
|
|
113
|
+
this.settle({ status: "ended" });
|
|
114
|
+
this.broadcast({ type: "ended", message });
|
|
115
|
+
this.broadcastMain();
|
|
116
|
+
}
|
|
117
|
+
/** Plays a script; resolves when it finishes, the user interjects, or ends the session. */
|
|
118
|
+
play(script, startAt, signal, onSegment, unattended = false) {
|
|
119
|
+
this.current = { script, index: startAt };
|
|
120
|
+
if (!script.aside && !script.transition)
|
|
121
|
+
this.main = this.current;
|
|
122
|
+
this.awaitPrompt = null;
|
|
123
|
+
this.status = null;
|
|
124
|
+
this.queued = null;
|
|
125
|
+
this.broadcast({ type: "play", script, startAt, autoplay: true });
|
|
126
|
+
this.broadcastMain();
|
|
127
|
+
return this.wait({ scriptId: script.id, resolve: () => { }, onSegment, unattended }, signal);
|
|
128
|
+
}
|
|
129
|
+
/** An agent (or the play CLI) is blocked in a call to us, so whatever the user does reaches it now. */
|
|
130
|
+
agentListening() {
|
|
131
|
+
return !!this.waiter && !this.waiter.unattended;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Plays a walkthrough opened from a link. If the agent is waiting on us, it's handed
|
|
135
|
+
* the request so it owns playback (and answers questions). Otherwise we play it
|
|
136
|
+
* ourselves, and a question waits for the agent's next await_user.
|
|
137
|
+
*/
|
|
138
|
+
async playShared(source) {
|
|
139
|
+
if (this.agentListening()) {
|
|
140
|
+
this.broadcast({ type: "stop" });
|
|
141
|
+
this.settle({ status: "shared", source });
|
|
142
|
+
this.expectAgent();
|
|
143
|
+
this.setStatus("Loading the shared walkthrough…");
|
|
144
|
+
return "agent";
|
|
145
|
+
}
|
|
146
|
+
const script = await this.shared.resolve(source);
|
|
147
|
+
this.touch();
|
|
148
|
+
void this.play(script, 0, undefined, undefined, true).then((o) => {
|
|
149
|
+
if (o.status !== "interrupted")
|
|
150
|
+
return;
|
|
151
|
+
// play() only settles: nobody is listening, so keep the question for the agent.
|
|
152
|
+
if (this.silenceTimer)
|
|
153
|
+
clearTimeout(this.silenceTimer);
|
|
154
|
+
this.silenceTimer = null;
|
|
155
|
+
this.queued = { status: "question", question: o.question, context: o.context };
|
|
156
|
+
this.setStatus("Your question is saved. To get an answer, tell your agent you asked something in pair.");
|
|
157
|
+
});
|
|
158
|
+
return "direct";
|
|
159
|
+
}
|
|
160
|
+
mainScript() {
|
|
161
|
+
return this.main;
|
|
162
|
+
}
|
|
163
|
+
/** The main walkthrough, when the user has stepped away from it (an aside or transition is current). */
|
|
164
|
+
pausedMain() {
|
|
165
|
+
return this.main && this.current !== this.main ? this.main : null;
|
|
166
|
+
}
|
|
167
|
+
mainMessage() {
|
|
168
|
+
const m = this.pausedMain();
|
|
169
|
+
return { type: "main", paused: m ? { title: m.script.title, index: m.index, total: m.script.segments.length } : null };
|
|
170
|
+
}
|
|
171
|
+
broadcastMain() {
|
|
172
|
+
this.broadcast(this.mainMessage());
|
|
173
|
+
}
|
|
174
|
+
/** Waits for a free-form question typed into the UI. */
|
|
175
|
+
awaitQuestion(prompt, options, timeoutMs, signal) {
|
|
176
|
+
if (this.queued) {
|
|
177
|
+
const q = this.queued;
|
|
178
|
+
this.queued = null;
|
|
179
|
+
return Promise.resolve(q);
|
|
180
|
+
}
|
|
181
|
+
this.awaitPrompt = { prompt, options };
|
|
182
|
+
this.broadcast({ type: "await", prompt, options });
|
|
183
|
+
const timer = setTimeout(() => this.settle({ status: "idle" }), timeoutMs);
|
|
184
|
+
return this.wait({ scriptId: null, resolve: () => { } }, signal).finally(() => clearTimeout(timer));
|
|
185
|
+
}
|
|
186
|
+
wait(w, signal) {
|
|
187
|
+
this.settle({ status: "cancelled" });
|
|
188
|
+
return new Promise((resolve) => {
|
|
189
|
+
w.resolve = resolve;
|
|
190
|
+
this.waiter = w;
|
|
191
|
+
this.sessions.update({ listening: this.agentListening() });
|
|
192
|
+
signal?.addEventListener("abort", () => {
|
|
193
|
+
if (this.waiter === w) {
|
|
194
|
+
this.settle({ status: "cancelled" });
|
|
195
|
+
this.broadcast({ type: "stop" });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
settle(o) {
|
|
201
|
+
const w = this.waiter;
|
|
202
|
+
this.waiter = null;
|
|
203
|
+
this.sessions.update({ listening: false });
|
|
204
|
+
w?.resolve(o);
|
|
205
|
+
}
|
|
206
|
+
onMessage(msg) {
|
|
207
|
+
const w = this.waiter;
|
|
208
|
+
switch (msg.type) {
|
|
209
|
+
case "segment":
|
|
210
|
+
if (this.current?.script.id === msg.id)
|
|
211
|
+
this.current.index = msg.index;
|
|
212
|
+
if (w?.scriptId === msg.id)
|
|
213
|
+
w.onSegment?.(msg.index);
|
|
214
|
+
break;
|
|
215
|
+
case "done":
|
|
216
|
+
if (w?.scriptId === msg.id)
|
|
217
|
+
this.settle({ status: "completed" });
|
|
218
|
+
break;
|
|
219
|
+
case "interrupt":
|
|
220
|
+
if (this.current && this.current.script.id === msg.id)
|
|
221
|
+
this.current.index = msg.index;
|
|
222
|
+
if (w) {
|
|
223
|
+
this.expectAgent();
|
|
224
|
+
this.settle(w.scriptId
|
|
225
|
+
? { status: "interrupted", index: msg.index, question: msg.question, context: msg.context }
|
|
226
|
+
: { status: "question", question: msg.question, context: msg.context, choice: msg.choice });
|
|
227
|
+
this.setStatus("Thinking…");
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
// Nobody is listening right now; hand it to the next await_user call.
|
|
231
|
+
this.queued = { status: "question", question: msg.question, context: msg.context, choice: msg.choice };
|
|
232
|
+
this.setStatus("Question queued — the agent will pick it up when it checks back in.");
|
|
233
|
+
}
|
|
234
|
+
break;
|
|
235
|
+
case "return": {
|
|
236
|
+
if (!this.pausedMain())
|
|
237
|
+
break;
|
|
238
|
+
const o = { status: "return", context: msg.context };
|
|
239
|
+
if (w) {
|
|
240
|
+
this.expectAgent();
|
|
241
|
+
this.settle(o);
|
|
242
|
+
this.setStatus("Heading back…");
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
this.queued = o;
|
|
246
|
+
this.setStatus("Got it — the agent will head back when it checks in.");
|
|
247
|
+
}
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
case "end":
|
|
251
|
+
this.endSession(null);
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
connect(sock) {
|
|
256
|
+
this.clients.add(sock);
|
|
257
|
+
const send = (m) => sock.send(JSON.stringify(m));
|
|
258
|
+
send({ type: "hello", root: this.ws.root, rootName: this.ws.name });
|
|
259
|
+
send({ type: "status", text: this.status });
|
|
260
|
+
if (this.ended)
|
|
261
|
+
send({ type: "ended", message: this.ended.message });
|
|
262
|
+
send({ type: "kokoro", status: this.kokoro.status });
|
|
263
|
+
send(this.mainMessage());
|
|
264
|
+
if (this.current)
|
|
265
|
+
send({ type: "play", script: this.current.script, startAt: this.current.index, autoplay: false });
|
|
266
|
+
if (this.waiter && !this.waiter.scriptId)
|
|
267
|
+
send({ type: "await", prompt: this.awaitPrompt?.prompt ?? null, options: this.awaitPrompt?.options });
|
|
268
|
+
sock.on("message", (data) => {
|
|
269
|
+
try {
|
|
270
|
+
this.onMessage(JSON.parse(String(data)));
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
log("bad message", e);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
sock.on("close", () => this.clients.delete(sock));
|
|
277
|
+
this.connectWaiters.splice(0).forEach((f) => f());
|
|
278
|
+
}
|
|
279
|
+
broadcast(m) {
|
|
280
|
+
const data = JSON.stringify(m);
|
|
281
|
+
for (const c of this.clients)
|
|
282
|
+
c.send(data);
|
|
283
|
+
}
|
|
284
|
+
async handle(req, res) {
|
|
285
|
+
if (!fromLocalPage(req))
|
|
286
|
+
return res.writeHead(403, { "content-type": "text/plain" }).end("Forbidden");
|
|
287
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
288
|
+
const json = (body, status = 200) => res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
|
|
289
|
+
if (url.pathname === "/api/tts/kokoro/enable" && req.method === "POST") {
|
|
290
|
+
this.kokoro.enable();
|
|
291
|
+
return json(this.kokoro.status);
|
|
292
|
+
}
|
|
293
|
+
if (url.pathname === "/api/tts/kokoro" && req.method === "POST") {
|
|
294
|
+
try {
|
|
295
|
+
const { text, voice, speed } = JSON.parse(await readBody(req));
|
|
296
|
+
return json(await this.kokoro.speak(String(text), String(voice || "af_heart"), Number(speed) || 1));
|
|
297
|
+
}
|
|
298
|
+
catch (e) {
|
|
299
|
+
return json({ error: e.message }, 503);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (url.pathname.startsWith("/api/shared/") && req.method === "POST") {
|
|
303
|
+
if (!this.shared)
|
|
304
|
+
return json({ error: "Shared walkthroughs aren't available." }, 503);
|
|
305
|
+
try {
|
|
306
|
+
const { src } = JSON.parse(await readBody(req));
|
|
307
|
+
if (url.pathname === "/api/shared/preview") {
|
|
308
|
+
const [preview, sessions] = await Promise.all([this.shared.preview(String(src)), this.sessions.list()]);
|
|
309
|
+
return json({ ...preview, sessions });
|
|
310
|
+
}
|
|
311
|
+
if (url.pathname === "/api/shared/play")
|
|
312
|
+
return json({ via: await this.playShared(String(src)) });
|
|
313
|
+
}
|
|
314
|
+
catch (e) {
|
|
315
|
+
return json({ error: e.message }, 400);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (url.pathname === "/api/list") {
|
|
319
|
+
try {
|
|
320
|
+
return json(await this.ws.list(url.searchParams.get("dir") ?? ""));
|
|
321
|
+
}
|
|
322
|
+
catch (e) {
|
|
323
|
+
return json({ error: e.message }, 400);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (url.pathname === "/api/file") {
|
|
327
|
+
try {
|
|
328
|
+
return json({ path: url.searchParams.get("path"), content: await this.ws.read(url.searchParams.get("path") ?? "") });
|
|
329
|
+
}
|
|
330
|
+
catch (e) {
|
|
331
|
+
return json({ error: e.message }, 404);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// "/play?src=…" is a link from a PR comment; the UI reads the query and asks before playing.
|
|
335
|
+
const rel = url.pathname === "/" || url.pathname === "/play" ? "index.html" : url.pathname.slice(1);
|
|
336
|
+
const file = path.resolve(WEB_DIR, rel);
|
|
337
|
+
if (!file.startsWith(WEB_DIR) || !existsSync(file)) {
|
|
338
|
+
if (!existsSync(path.join(WEB_DIR, "index.html"))) {
|
|
339
|
+
return res.writeHead(500, { "content-type": "text/plain" }).end("UI not built. Run `npm run build` in the pair repo.");
|
|
340
|
+
}
|
|
341
|
+
return res.writeHead(404).end();
|
|
342
|
+
}
|
|
343
|
+
res.writeHead(200, { "content-type": MIME[path.extname(file)] ?? "application/octet-stream" });
|
|
344
|
+
createReadStream(file).pipe(res);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function readBody(req) {
|
|
348
|
+
return new Promise((resolve, reject) => {
|
|
349
|
+
let body = "";
|
|
350
|
+
req.on("data", (d) => {
|
|
351
|
+
body += d;
|
|
352
|
+
if (body.length > 1_000_000)
|
|
353
|
+
req.destroy(new Error("Request too large"));
|
|
354
|
+
});
|
|
355
|
+
req.on("end", () => resolve(body));
|
|
356
|
+
req.on("error", reject);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
const LOCAL_HOST = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
|
|
360
|
+
/**
|
|
361
|
+
* Only the Pair UI itself may talk to this server. The Host check defeats DNS
|
|
362
|
+
* rebinding (a remote page reaching us through its own domain); the Origin check
|
|
363
|
+
* stops other websites from opening the WebSocket (which would let them feed
|
|
364
|
+
* "user questions" to the agent) or POSTing to the API. Browsers always send
|
|
365
|
+
* Origin on WebSocket handshakes and cross-site POSTs; local tools like curl
|
|
366
|
+
* send none and are allowed.
|
|
367
|
+
*/
|
|
368
|
+
function fromLocalPage(req) {
|
|
369
|
+
if (!LOCAL_HOST.test(req.headers.host ?? ""))
|
|
370
|
+
return false;
|
|
371
|
+
const origin = req.headers.origin;
|
|
372
|
+
if (!origin)
|
|
373
|
+
return true;
|
|
374
|
+
try {
|
|
375
|
+
const u = new URL(origin);
|
|
376
|
+
return u.protocol === "http:" && LOCAL_HOST.test(u.host);
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
}
|