parley-chat 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/README.md +18 -0
- package/main.mjs +540 -0
- package/package.json +11 -0
- package/parley.mjs +2 -0
package/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# parley
|
|
2
|
+
|
|
3
|
+
Your friend's agent asks your agent a question. Your agent drafts an answer
|
|
4
|
+
from your real local context. You approve it from a Discord DM before it
|
|
5
|
+
sends. Nothing is stored after delivery.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install -g parley-chat # or: npx parley-chat
|
|
9
|
+
parley login # Discord sign-in
|
|
10
|
+
parley skills # full setup guide (written for your agent to read)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Connect with a friend: one side runs `parley invite`, the other runs
|
|
14
|
+
`parley join <code>`, the inviter taps Connect in the bot DM. Then:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
parley ask @friend "how is your dev setup wired?"
|
|
18
|
+
```
|
package/main.mjs
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __returnValue = (v) => v;
|
|
5
|
+
function __exportSetter(name, newValue) {
|
|
6
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
7
|
+
}
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, {
|
|
11
|
+
get: all[name],
|
|
12
|
+
enumerable: true,
|
|
13
|
+
configurable: true,
|
|
14
|
+
set: __exportSetter.bind(all, name)
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
18
|
+
|
|
19
|
+
// src/files.ts
|
|
20
|
+
var exports_files = {};
|
|
21
|
+
__export(exports_files, {
|
|
22
|
+
saveCredentials: () => saveCredentials,
|
|
23
|
+
saveConfig: () => saveConfig,
|
|
24
|
+
removeCredentials: () => removeCredentials,
|
|
25
|
+
relayUrl: () => relayUrl,
|
|
26
|
+
parleyHome: () => parleyHome,
|
|
27
|
+
fileExists: () => fileExists,
|
|
28
|
+
credentials: () => credentials,
|
|
29
|
+
config: () => config
|
|
30
|
+
});
|
|
31
|
+
import { chmod, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
32
|
+
import { homedir } from "node:os";
|
|
33
|
+
import { join } from "node:path";
|
|
34
|
+
function parleyHome() {
|
|
35
|
+
return process.env.PARLEY_HOME ?? join(homedir(), ".parley");
|
|
36
|
+
}
|
|
37
|
+
function relayUrl() {
|
|
38
|
+
return process.env.PARLEY_RELAY ?? "https://parley-relay.rhys-669.workers.dev";
|
|
39
|
+
}
|
|
40
|
+
async function credentials() {
|
|
41
|
+
return JSON.parse(await readFile(join(parleyHome(), "credentials.json"), "utf8"));
|
|
42
|
+
}
|
|
43
|
+
async function saveCredentials(value) {
|
|
44
|
+
await mkdir(parleyHome(), { recursive: true, mode: 448 });
|
|
45
|
+
const path = join(parleyHome(), "credentials.json");
|
|
46
|
+
await writeFile(path, JSON.stringify(value, null, 2), { mode: 384 });
|
|
47
|
+
await chmod(path, 384);
|
|
48
|
+
}
|
|
49
|
+
async function removeCredentials() {
|
|
50
|
+
await rm(join(parleyHome(), "credentials.json"), { force: true });
|
|
51
|
+
}
|
|
52
|
+
async function config() {
|
|
53
|
+
const source = await readFile(join(parleyHome(), "config.toml"), "utf8");
|
|
54
|
+
const command = /^command\s*=\s*"([^"]+)"\s*$/m.exec(source)?.[1];
|
|
55
|
+
const context = /^context\s*=\s*"""\n([\s\S]*?)\n"""\s*$/m.exec(source)?.[1];
|
|
56
|
+
if (!command || context === undefined)
|
|
57
|
+
throw new Error("invalid ~/.parley/config.toml");
|
|
58
|
+
return { command, context };
|
|
59
|
+
}
|
|
60
|
+
async function saveConfig(value) {
|
|
61
|
+
await mkdir(parleyHome(), { recursive: true, mode: 448 });
|
|
62
|
+
await writeFile(join(parleyHome(), "config.toml"), `command = ${JSON.stringify(value.command)}
|
|
63
|
+
context = """
|
|
64
|
+
${value.context}
|
|
65
|
+
"""
|
|
66
|
+
`, { mode: 384 });
|
|
67
|
+
}
|
|
68
|
+
async function fileExists(path) {
|
|
69
|
+
try {
|
|
70
|
+
await stat(path);
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
var init_files = () => {};
|
|
77
|
+
|
|
78
|
+
// src/main.ts
|
|
79
|
+
import { execFile } from "child_process";
|
|
80
|
+
import { mkdir as mkdir3, readFile as readFile3, rm as rm3, stat as stat3, writeFile as writeFile3 } from "fs/promises";
|
|
81
|
+
import { homedir as homedir3 } from "os";
|
|
82
|
+
import { join as join3 } from "path";
|
|
83
|
+
import { promisify } from "util";
|
|
84
|
+
|
|
85
|
+
// ../shared/src/protocol.ts
|
|
86
|
+
var skillDocument = `---
|
|
87
|
+
name: parley
|
|
88
|
+
description: Ask a mutual contact's local agent a question through Parley, with both inbound execution and outbound replies approved in Discord.
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
# Parley
|
|
92
|
+
|
|
93
|
+
Parley lets your human exchange one-off questions with a friend's local agent. The relay is ephemeral: it keeps accounts, contacts, unexpired invites, and only active requests. Every agent-written reply requires the owner's Discord approval before delivery.
|
|
94
|
+
|
|
95
|
+
## Setup
|
|
96
|
+
|
|
97
|
+
1. Install the CLI and authenticate: \`parley login\`.
|
|
98
|
+
2. Create \`~/.parley/config.toml\` with a command that reads stdin and writes a draft to stdout:
|
|
99
|
+
|
|
100
|
+
\`\`\`toml
|
|
101
|
+
command = "claude -p"
|
|
102
|
+
context = """
|
|
103
|
+
You are answering questions asked by my friends through Parley.
|
|
104
|
+
Use only the local paths my human permits. Never reveal secrets or tokens.
|
|
105
|
+
"""
|
|
106
|
+
\`\`\`
|
|
107
|
+
|
|
108
|
+
3. Start the responder with \`parley listen\`, or install the macOS launchd agent once with \`parley install\`.
|
|
109
|
+
4. Add a friend: \`parley invite\`, send the six-character code, then have them run \`parley join CODE\`. Their redemption stays pending until you tap **Connect** in the Discord DM.
|
|
110
|
+
|
|
111
|
+
## Commands
|
|
112
|
+
|
|
113
|
+
- \`parley login\`: starts Discord OAuth with the \`identify\` scope and stores a local opaque relay token.
|
|
114
|
+
- \`parley logout\`: deletes the local token.
|
|
115
|
+
- \`parley invite\`: creates a single-use code that expires after 48 hours.
|
|
116
|
+
- \`parley join CODE\`: redeems a friend's invite and waits for their approval.
|
|
117
|
+
- \`parley contacts\`: lists mutual contacts and whether their responder is connected.
|
|
118
|
+
- \`parley remove NAME\`: removes a mutual contact for both people.
|
|
119
|
+
- \`parley ask @NAME "question"\`: blocks for a one-off response. It fails immediately when the responder is offline.
|
|
120
|
+
- \`parley listen\`: runs the local responder in the foreground.
|
|
121
|
+
- \`parley install\` / \`parley uninstall\`: manages the macOS launchd responder.
|
|
122
|
+
- \`parley status\`: reports local responder and configuration status.
|
|
123
|
+
- \`parley logs\`: tails the launchd responder log.
|
|
124
|
+
- \`parley skills --write\`: writes this document to \`.claude/skills/parley/SKILL.md\` in the current directory.
|
|
125
|
+
|
|
126
|
+
## Approval flow
|
|
127
|
+
|
|
128
|
+
When a friend asks, the owner first sees the asker identity and exact question in a Discord DM. **Run** permits the local command to receive a composed prompt containing the configured context, asker username, and question. **Deny** stops it. **Deny with reason** opens a Discord modal and forwards the human-written reason to the asker.
|
|
129
|
+
|
|
130
|
+
After the command drafts stdout, the owner sees a second Discord DM. **Approve** delivers it; **Deny** or **Deny with reason** rejects it. Empty stdout or a nonzero command exit automatically denies with \`agent failed to draft\`.
|
|
131
|
+
|
|
132
|
+
## Agent use
|
|
133
|
+
|
|
134
|
+
Use a literal question and a mutual contact name:
|
|
135
|
+
|
|
136
|
+
\`\`\`sh
|
|
137
|
+
parley ask @dave "Where is the deployment checklist?"
|
|
138
|
+
\`\`\`
|
|
139
|
+
|
|
140
|
+
Do not assume a reply will be delivered. The recipient can deny, be offline, or let the request expire after ten minutes. Do not put secrets in questions. Parley is not a chat system: there are no threads, queues, groups, history, or automatic approval.
|
|
141
|
+
`;
|
|
142
|
+
|
|
143
|
+
// src/http.ts
|
|
144
|
+
init_files();
|
|
145
|
+
async function api(path, init = {}) {
|
|
146
|
+
const auth = await credentials();
|
|
147
|
+
return fetch(`${relayUrl()}${path}`, { ...init, headers: { authorization: `Bearer ${auth.token}`, "content-type": "application/json", ...init.headers } });
|
|
148
|
+
}
|
|
149
|
+
async function apiJson(path, init = {}) {
|
|
150
|
+
const response = await api(path, init);
|
|
151
|
+
if (!response.ok) {
|
|
152
|
+
const body = await response.text();
|
|
153
|
+
throw new Error(body);
|
|
154
|
+
}
|
|
155
|
+
return response.json();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/files.ts
|
|
159
|
+
import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, rm as rm2, stat as stat2, writeFile as writeFile2 } from "node:fs/promises";
|
|
160
|
+
import { homedir as homedir2 } from "node:os";
|
|
161
|
+
import { join as join2 } from "node:path";
|
|
162
|
+
function parleyHome2() {
|
|
163
|
+
return process.env.PARLEY_HOME ?? join2(homedir2(), ".parley");
|
|
164
|
+
}
|
|
165
|
+
function relayUrl2() {
|
|
166
|
+
return process.env.PARLEY_RELAY ?? "https://parley-relay.rhys-669.workers.dev";
|
|
167
|
+
}
|
|
168
|
+
async function credentials2() {
|
|
169
|
+
return JSON.parse(await readFile2(join2(parleyHome2(), "credentials.json"), "utf8"));
|
|
170
|
+
}
|
|
171
|
+
async function saveCredentials2(value) {
|
|
172
|
+
await mkdir2(parleyHome2(), { recursive: true, mode: 448 });
|
|
173
|
+
const path = join2(parleyHome2(), "credentials.json");
|
|
174
|
+
await writeFile2(path, JSON.stringify(value, null, 2), { mode: 384 });
|
|
175
|
+
await chmod2(path, 384);
|
|
176
|
+
}
|
|
177
|
+
async function removeCredentials2() {
|
|
178
|
+
await rm2(join2(parleyHome2(), "credentials.json"), { force: true });
|
|
179
|
+
}
|
|
180
|
+
async function config2() {
|
|
181
|
+
const source = await readFile2(join2(parleyHome2(), "config.toml"), "utf8");
|
|
182
|
+
const command = /^command\s*=\s*"([^"]+)"\s*$/m.exec(source)?.[1];
|
|
183
|
+
const context = /^context\s*=\s*"""\n([\s\S]*?)\n"""\s*$/m.exec(source)?.[1];
|
|
184
|
+
if (!command || context === undefined)
|
|
185
|
+
throw new Error("invalid ~/.parley/config.toml");
|
|
186
|
+
return { command, context };
|
|
187
|
+
}
|
|
188
|
+
async function fileExists2(path) {
|
|
189
|
+
try {
|
|
190
|
+
await stat2(path);
|
|
191
|
+
return true;
|
|
192
|
+
} catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// src/responder.ts
|
|
198
|
+
import { spawn } from "node:child_process";
|
|
199
|
+
|
|
200
|
+
// src/http.ts
|
|
201
|
+
init_files();
|
|
202
|
+
async function api2(path, init = {}) {
|
|
203
|
+
const auth = await credentials();
|
|
204
|
+
return fetch(`${relayUrl()}${path}`, { ...init, headers: { authorization: `Bearer ${auth.token}`, "content-type": "application/json", ...init.headers } });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// src/responder.ts
|
|
208
|
+
init_files();
|
|
209
|
+
function wsUrl(path) {
|
|
210
|
+
return `${relayUrl().replace(/^http/, "ws")}${path}`;
|
|
211
|
+
}
|
|
212
|
+
async function draft(requestId, asker, question) {
|
|
213
|
+
const local = await config();
|
|
214
|
+
const prompt = `${local.context}
|
|
215
|
+
|
|
216
|
+
Asker: ${asker}
|
|
217
|
+
Question: ${question}
|
|
218
|
+
`;
|
|
219
|
+
const child = spawn("sh", ["-c", local.command], { stdio: ["pipe", "pipe", "ignore"] });
|
|
220
|
+
child.stdin.write(prompt);
|
|
221
|
+
child.stdin.end();
|
|
222
|
+
const chunks = [];
|
|
223
|
+
child.stdout.on("data", (chunk) => chunks.push(chunk));
|
|
224
|
+
const code = await new Promise((resolve) => child.on("close", (value) => resolve(value ?? 1)));
|
|
225
|
+
const output = Buffer.concat(chunks).toString("utf8").trim();
|
|
226
|
+
await api2(`/v1/asks/${requestId}/draft`, { method: "POST", body: JSON.stringify({ draft: code === 0 ? output : "" }) });
|
|
227
|
+
}
|
|
228
|
+
async function listen() {
|
|
229
|
+
const auth = await Promise.resolve().then(() => (init_files(), exports_files)).then((module) => module.credentials());
|
|
230
|
+
const socket = new WebSocket(wsUrl("/v1/responders/connect"), [`parley.${auth.token}`]);
|
|
231
|
+
await new Promise((resolve, reject) => {
|
|
232
|
+
socket.addEventListener("open", () => resolve(), { once: true });
|
|
233
|
+
socket.addEventListener("error", () => reject(new Error("responder connection failed")), { once: true });
|
|
234
|
+
});
|
|
235
|
+
process.stderr.write(`listening
|
|
236
|
+
`);
|
|
237
|
+
const questions = new Map;
|
|
238
|
+
socket.addEventListener("message", (event) => {
|
|
239
|
+
const value = JSON.parse(String(event.data));
|
|
240
|
+
if (value.type === "question") {
|
|
241
|
+
questions.set(value.requestId, value);
|
|
242
|
+
process.stderr.write(`waiting for approval from ${value.asker.username}…
|
|
243
|
+
`);
|
|
244
|
+
}
|
|
245
|
+
if (value.type === "run") {
|
|
246
|
+
const question = questions.get(value.requestId);
|
|
247
|
+
if (question)
|
|
248
|
+
draft(question.requestId, question.asker.username, question.question);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
await new Promise((resolve) => socket.addEventListener("close", () => resolve(), { once: true }));
|
|
252
|
+
return 1;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// src/main.ts
|
|
256
|
+
var execute = promisify(execFile);
|
|
257
|
+
function object(value) {
|
|
258
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
259
|
+
}
|
|
260
|
+
function text(value) {
|
|
261
|
+
return typeof value === "string" ? value : null;
|
|
262
|
+
}
|
|
263
|
+
function wsUrl2(path) {
|
|
264
|
+
return `${relayUrl2().replace(/^http/, "ws")}${path}`;
|
|
265
|
+
}
|
|
266
|
+
function sleep(milliseconds) {
|
|
267
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
268
|
+
}
|
|
269
|
+
function oneTimeCode() {
|
|
270
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
271
|
+
return Buffer.from(bytes).toString("base64url");
|
|
272
|
+
}
|
|
273
|
+
async function login(args) {
|
|
274
|
+
const mockId = args[0] ?? process.env.PARLEY_MOCK_ID;
|
|
275
|
+
const mockUsername = args[1] ?? process.env.PARLEY_MOCK_USERNAME;
|
|
276
|
+
if (mockId && mockUsername) {
|
|
277
|
+
const response = await fetch(`${relayUrl2()}/v1/mock/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: mockId, username: mockUsername, avatar: null }) });
|
|
278
|
+
const value = object(await response.json());
|
|
279
|
+
const token = value ? text(value.token) : null;
|
|
280
|
+
const account = value ? object(value.account) : null;
|
|
281
|
+
if (!token || !account)
|
|
282
|
+
throw new Error("mock login failed");
|
|
283
|
+
await saveCredentials2({ token, account: { id: text(account.id) ?? "", username: text(account.username) ?? "", avatar: account.avatar === null ? null : text(account.avatar) } });
|
|
284
|
+
process.stdout.write(`logged in as ${text(account.username)}
|
|
285
|
+
`);
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
const loginCode = oneTimeCode();
|
|
289
|
+
const url = `${relayUrl2()}/oauth/login?login_code=${encodeURIComponent(loginCode)}`;
|
|
290
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "linux" ? "xdg-open" : null;
|
|
291
|
+
if (opener)
|
|
292
|
+
await execute(opener, [url]).catch(() => {
|
|
293
|
+
return;
|
|
294
|
+
});
|
|
295
|
+
process.stderr.write(`complete Discord login in your browser
|
|
296
|
+
`);
|
|
297
|
+
process.stdout.write(`${url}
|
|
298
|
+
`);
|
|
299
|
+
for (let attempt = 0;attempt < 600; attempt += 1) {
|
|
300
|
+
const response = await fetch(`${relayUrl2()}/oauth/poll`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ loginCode }) });
|
|
301
|
+
const value = response.ok ? object(await response.json()) : null;
|
|
302
|
+
if (value?.status === "complete") {
|
|
303
|
+
const token = text(value.token);
|
|
304
|
+
const account = object(value.account);
|
|
305
|
+
const id = account ? text(account.id) : null;
|
|
306
|
+
const username = account ? text(account.username) : null;
|
|
307
|
+
const avatar = account?.avatar === null ? null : account ? text(account.avatar) : null;
|
|
308
|
+
if (!token || !id || !username)
|
|
309
|
+
throw new Error("invalid oauth login response");
|
|
310
|
+
await saveCredentials2({ token, account: { id, username, avatar } });
|
|
311
|
+
process.stdout.write(`logged in as ${username}
|
|
312
|
+
`);
|
|
313
|
+
return 0;
|
|
314
|
+
}
|
|
315
|
+
if (!response.ok && response.status !== 404)
|
|
316
|
+
throw new Error("oauth login failed");
|
|
317
|
+
await sleep(1000);
|
|
318
|
+
}
|
|
319
|
+
throw new Error("login timed out");
|
|
320
|
+
}
|
|
321
|
+
async function invite() {
|
|
322
|
+
const value = object(await apiJson("/v1/invites", { method: "POST" }));
|
|
323
|
+
const code = value ? text(value.code) : null;
|
|
324
|
+
if (!code)
|
|
325
|
+
throw new Error("invalid invite response");
|
|
326
|
+
process.stdout.write(`${code}
|
|
327
|
+
${relayUrl2().replace(/^http:\/\//, "https://")}/i/${code}
|
|
328
|
+
`);
|
|
329
|
+
return 0;
|
|
330
|
+
}
|
|
331
|
+
async function redeemInvite(code) {
|
|
332
|
+
if (!code)
|
|
333
|
+
throw new Error("usage: parley join <code>");
|
|
334
|
+
await apiJson("/v1/join", { method: "POST", body: JSON.stringify({ code }) });
|
|
335
|
+
process.stdout.write(`pending
|
|
336
|
+
`);
|
|
337
|
+
return 0;
|
|
338
|
+
}
|
|
339
|
+
async function contacts() {
|
|
340
|
+
const value = object(await apiJson("/v1/contacts"));
|
|
341
|
+
const entries = Array.isArray(value?.contacts) ? value.contacts : [];
|
|
342
|
+
for (const entry of entries) {
|
|
343
|
+
const contact = object(entry);
|
|
344
|
+
if (contact)
|
|
345
|
+
process.stdout.write(`${text(contact.username)} (${contact.online === true ? "online" : "offline"})
|
|
346
|
+
`);
|
|
347
|
+
}
|
|
348
|
+
return 0;
|
|
349
|
+
}
|
|
350
|
+
async function remove(name) {
|
|
351
|
+
if (!name)
|
|
352
|
+
throw new Error("usage: parley remove <name>");
|
|
353
|
+
const value = object(await apiJson("/v1/contacts"));
|
|
354
|
+
const match = (Array.isArray(value?.contacts) ? value.contacts : []).map(object).find((contact) => contact && text(contact.username) === name);
|
|
355
|
+
if (!match || !text(match.id))
|
|
356
|
+
throw new Error("not a contact");
|
|
357
|
+
await apiJson("/v1/contacts/remove", { method: "POST", body: JSON.stringify({ contactId: text(match.id) }) });
|
|
358
|
+
process.stdout.write(`removed
|
|
359
|
+
`);
|
|
360
|
+
return 0;
|
|
361
|
+
}
|
|
362
|
+
async function ask(rawName, question) {
|
|
363
|
+
if (!rawName || !question)
|
|
364
|
+
throw new Error("usage: parley ask @<name> <question>");
|
|
365
|
+
const name = rawName.replace(/^@/, "");
|
|
366
|
+
const contactValue = object(await apiJson("/v1/contacts"));
|
|
367
|
+
const recipient = (Array.isArray(contactValue?.contacts) ? contactValue.contacts : []).map(object).find((contact) => contact && text(contact.username) === name);
|
|
368
|
+
const recipientId = recipient ? text(recipient.id) : null;
|
|
369
|
+
if (!recipientId) {
|
|
370
|
+
process.stderr.write(`not a contact
|
|
371
|
+
`);
|
|
372
|
+
return 2;
|
|
373
|
+
}
|
|
374
|
+
const start = await api("/v1/asks", { method: "POST", body: JSON.stringify({ recipientId, question }) });
|
|
375
|
+
const started = object(await start.json());
|
|
376
|
+
if (started?.status === "offline") {
|
|
377
|
+
process.stderr.write(`${name}'s agent is offline right now \u2014 try again later.
|
|
378
|
+
`);
|
|
379
|
+
return 2;
|
|
380
|
+
}
|
|
381
|
+
const requestId = started ? text(started.requestId) : null;
|
|
382
|
+
if (!start.ok || !requestId)
|
|
383
|
+
throw new Error("ask failed");
|
|
384
|
+
process.stderr.write(`waiting for ${name}\u2026
|
|
385
|
+
`);
|
|
386
|
+
const auth = await credentials2();
|
|
387
|
+
const socket = new WebSocket(wsUrl2(`/v1/asks/${requestId}/events?recipient=${encodeURIComponent(recipientId)}`), [`parley.${auth.token}`]);
|
|
388
|
+
return new Promise((resolve, reject) => {
|
|
389
|
+
socket.addEventListener("message", (event) => {
|
|
390
|
+
const frame = JSON.parse(String(event.data));
|
|
391
|
+
if (frame.type === "state")
|
|
392
|
+
process.stderr.write(frame.stage === "drafting" ? `${name}'s agent is drafting\u2026
|
|
393
|
+
` : `waiting for approval\u2026
|
|
394
|
+
`);
|
|
395
|
+
if (frame.type === "answer") {
|
|
396
|
+
process.stdout.write(`${frame.answer}
|
|
397
|
+
`);
|
|
398
|
+
socket.close();
|
|
399
|
+
resolve(0);
|
|
400
|
+
}
|
|
401
|
+
if (frame.type === "declined") {
|
|
402
|
+
process.stdout.write(frame.reason ? `declined: ${frame.reason}
|
|
403
|
+
` : `declined
|
|
404
|
+
`);
|
|
405
|
+
socket.close();
|
|
406
|
+
resolve(2);
|
|
407
|
+
}
|
|
408
|
+
if (frame.type === "timeout") {
|
|
409
|
+
process.stdout.write(`timed out
|
|
410
|
+
`);
|
|
411
|
+
socket.close();
|
|
412
|
+
resolve(2);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
socket.addEventListener("error", () => reject(new Error("ask connection failed")), { once: true });
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
function plist(entry) {
|
|
419
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
420
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
421
|
+
<plist version="1.0"><dict><key>Label</key><string>sh.parley.listen</string><key>ProgramArguments</key><array><string>${entry}</string><string>listen</string></array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>StandardOutPath</key><string>${join3(parleyHome2(), "logs/listen.log")}</string><key>StandardErrorPath</key><string>${join3(parleyHome2(), "logs/listen.log")}</string></dict></plist>
|
|
422
|
+
`;
|
|
423
|
+
}
|
|
424
|
+
async function install() {
|
|
425
|
+
if (process.platform !== "darwin") {
|
|
426
|
+
process.stderr.write(`not supported yet
|
|
427
|
+
`);
|
|
428
|
+
return 1;
|
|
429
|
+
}
|
|
430
|
+
const path = join3(homedir3(), "Library/LaunchAgents/sh.parley.listen.plist");
|
|
431
|
+
await mkdir3(join3(parleyHome2(), "logs"), { recursive: true, mode: 448 });
|
|
432
|
+
await writeFile3(path, plist(process.argv[1] ?? "parley"), { mode: 384 });
|
|
433
|
+
await execute("launchctl", ["bootstrap", `gui/${process.getuid?.() ?? 501}`, path]);
|
|
434
|
+
process.stdout.write(`installed
|
|
435
|
+
`);
|
|
436
|
+
return 0;
|
|
437
|
+
}
|
|
438
|
+
async function uninstall() {
|
|
439
|
+
if (process.platform !== "darwin") {
|
|
440
|
+
process.stderr.write(`not supported yet
|
|
441
|
+
`);
|
|
442
|
+
return 1;
|
|
443
|
+
}
|
|
444
|
+
const path = join3(homedir3(), "Library/LaunchAgents/sh.parley.listen.plist");
|
|
445
|
+
await execute("launchctl", ["bootout", `gui/${process.getuid?.() ?? 501}`, path]).catch(() => {
|
|
446
|
+
return;
|
|
447
|
+
});
|
|
448
|
+
await rm3(path, { force: true });
|
|
449
|
+
process.stdout.write(`uninstalled
|
|
450
|
+
`);
|
|
451
|
+
return 0;
|
|
452
|
+
}
|
|
453
|
+
async function status() {
|
|
454
|
+
const hasCredentials = await fileExists2(join3(parleyHome2(), "credentials.json"));
|
|
455
|
+
const local = await config2().catch(() => null);
|
|
456
|
+
const online = hasCredentials ? object(await apiJson("/v1/status").catch(() => null))?.online === true : false;
|
|
457
|
+
const logPath = join3(parleyHome2(), "logs/listen.log");
|
|
458
|
+
const lastActivity = await stat3(logPath).then((entry) => entry.mtime.toISOString()).catch(() => "never");
|
|
459
|
+
const launchAgent = await launchAgentState();
|
|
460
|
+
const summary = local ? `${local.command} (${local.context.split(`
|
|
461
|
+
`).filter((line) => line.length > 0).length} context lines)` : "missing";
|
|
462
|
+
process.stdout.write(`launchagent: ${launchAgent}
|
|
463
|
+
responder: ${online ? "online" : "offline"}
|
|
464
|
+
last activity: ${lastActivity}
|
|
465
|
+
credentials: ${hasCredentials ? "present" : "missing"}
|
|
466
|
+
config: ${summary}
|
|
467
|
+
`);
|
|
468
|
+
return hasCredentials && local ? 0 : 1;
|
|
469
|
+
}
|
|
470
|
+
async function launchAgentState() {
|
|
471
|
+
if (process.platform !== "darwin")
|
|
472
|
+
return "not supported yet";
|
|
473
|
+
const uid = process.getuid?.();
|
|
474
|
+
if (uid === undefined)
|
|
475
|
+
return "unknown";
|
|
476
|
+
const result = await execute("launchctl", ["print", `gui/${uid}/sh.parley.listen`]).then(() => "loaded").catch(() => "not loaded");
|
|
477
|
+
return result;
|
|
478
|
+
}
|
|
479
|
+
async function logs() {
|
|
480
|
+
const path = join3(parleyHome2(), "logs/listen.log");
|
|
481
|
+
process.stdout.write((await readFile3(path, "utf8").catch(() => `no logs
|
|
482
|
+
`)).split(`
|
|
483
|
+
`).slice(-100).join(`
|
|
484
|
+
`));
|
|
485
|
+
return 0;
|
|
486
|
+
}
|
|
487
|
+
async function skills(write) {
|
|
488
|
+
if (write) {
|
|
489
|
+
const path = join3(process.cwd(), ".claude/skills/parley");
|
|
490
|
+
await mkdir3(path, { recursive: true });
|
|
491
|
+
await writeFile3(join3(path, "SKILL.md"), skillDocument);
|
|
492
|
+
process.stdout.write(`wrote .claude/skills/parley/SKILL.md
|
|
493
|
+
`);
|
|
494
|
+
} else
|
|
495
|
+
process.stdout.write(skillDocument);
|
|
496
|
+
return 0;
|
|
497
|
+
}
|
|
498
|
+
async function main() {
|
|
499
|
+
const [command, ...args] = process.argv.slice(2);
|
|
500
|
+
switch (command) {
|
|
501
|
+
case "login":
|
|
502
|
+
return login(args);
|
|
503
|
+
case "logout":
|
|
504
|
+
await removeCredentials2();
|
|
505
|
+
process.stdout.write(`logged out
|
|
506
|
+
`);
|
|
507
|
+
return 0;
|
|
508
|
+
case "invite":
|
|
509
|
+
return invite();
|
|
510
|
+
case "join":
|
|
511
|
+
return redeemInvite(args[0]);
|
|
512
|
+
case "contacts":
|
|
513
|
+
return contacts();
|
|
514
|
+
case "remove":
|
|
515
|
+
return remove(args[0]);
|
|
516
|
+
case "ask":
|
|
517
|
+
return ask(args[0], args.slice(1).join(" "));
|
|
518
|
+
case "listen":
|
|
519
|
+
return listen();
|
|
520
|
+
case "install":
|
|
521
|
+
return install();
|
|
522
|
+
case "uninstall":
|
|
523
|
+
return uninstall();
|
|
524
|
+
case "status":
|
|
525
|
+
return status();
|
|
526
|
+
case "logs":
|
|
527
|
+
return logs();
|
|
528
|
+
case "skills":
|
|
529
|
+
return skills(args[0] === "--write");
|
|
530
|
+
default:
|
|
531
|
+
process.stderr.write(`usage: parley <login|logout|invite|join|contacts|remove|ask|listen|install|uninstall|status|logs|skills>
|
|
532
|
+
`);
|
|
533
|
+
return 1;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
main().then((code) => process.exit(code)).catch((error) => {
|
|
537
|
+
process.stderr.write(`${error instanceof Error ? error.message : "parley failed"}
|
|
538
|
+
`);
|
|
539
|
+
process.exit(1);
|
|
540
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "parley-chat",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Ask your friends' local agents questions, with every reply approved by its human in Discord.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "parley": "parley.mjs" },
|
|
7
|
+
"files": ["parley.mjs", "main.mjs"],
|
|
8
|
+
"engines": { "node": ">=22" },
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": { "type": "git", "url": "https://github.com/rhyssullivan/parley" }
|
|
11
|
+
}
|
package/parley.mjs
ADDED