zas-agent 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.1] - 2026-09-03
11
+
12
+ ### Fixed
13
+
14
+ - The `claude mcp add` and `codex mcp add` lines the terminal prints after
15
+ pairing quote the `--` separator. In PowerShell the npm-installed `claude`
16
+ and `codex` commands are script shims, and PowerShell kept a bare `--` for
17
+ itself, so `claude mcp add` refused `-y` as an unknown option.
18
+
19
+ ## [0.2.0] - 2026-09-03
20
+
21
+ ### Changed
22
+
23
+ - Pairing no longer prints a code to type. The terminal listens on
24
+ `127.0.0.1`, the approval page hands it a one-time claim code, and the
25
+ agent is created only when the terminal claims. A page that cannot reach
26
+ the terminal shows the code, and the terminal asks for it. `pair` opens
27
+ the browser; `--no-open` and `ZAS_NO_OPEN=1` keep it closed.
28
+ - `zas_pair` takes an optional `code` for the case where the page shows one.
29
+ - Older servers are not supported by this release: the claim route is
30
+ required. Older CLIs keep working against the server.
31
+
10
32
  ## [0.1.1] - 2026-09-02
11
33
 
12
34
  ### Changed
@@ -36,6 +58,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
36
58
  two separate identities that cannot read each other's keys.
37
59
  - Install snippets for Claude Code and Codex, printed by `zas-agent pair`.
38
60
 
39
- [Unreleased]: https://github.com/soke1556/zas-agent/compare/v0.1.1...HEAD
61
+ [Unreleased]: https://github.com/soke1556/zas-agent/compare/v0.2.1...HEAD
62
+ [0.2.1]: https://github.com/soke1556/zas-agent/compare/v0.2.0...v0.2.1
63
+ [0.2.0]: https://github.com/soke1556/zas-agent/compare/v0.1.1...v0.2.0
40
64
  [0.1.1]: https://github.com/soke1556/zas-agent/compare/v0.1.0...v0.1.1
41
65
  [0.1.0]: https://github.com/soke1556/zas-agent/releases/tag/v0.1.0
package/README.md CHANGED
@@ -46,13 +46,13 @@ npx -y zas-agent pair --profile claude-code
46
46
  **Claude Code**
47
47
 
48
48
  ```
49
- claude mcp add zas -- npx -y zas-agent --profile claude-code
49
+ claude mcp add zas "--" npx -y zas-agent --profile claude-code
50
50
  ```
51
51
 
52
52
  **Codex**
53
53
 
54
54
  ```
55
- codex mcp add zas -- npx -y zas-agent --profile codex
55
+ codex mcp add zas "--" npx -y zas-agent --profile codex
56
56
  ```
57
57
 
58
58
  Codex by hand, if you would rather edit `~/.codex/config.toml`:
@@ -63,28 +63,37 @@ command = "npx"
63
63
  args = ["-y", "zas-agent", "--profile", "codex"]
64
64
  ```
65
65
 
66
- ### What pairing prints
66
+ ### What pairing does
67
67
 
68
- `zas-agent pair` mints the key pair, registers the public halves, and waits. The
69
- terminal shows four things:
68
+ `zas-agent pair` mints the key pair, registers the public halves, opens the
69
+ approval page in your browser, and waits. The terminal shows:
70
70
 
71
71
  ```
72
72
  Open this page signed in to your Zas account:
73
- https://zas.red/agents/pair?p=...
74
- Code: ABCD-EFGH
73
+ https://zas.red/agents/pair?p=...#port=53211
75
74
  Fingerprint: 1a2b 3c4d 5e6f 7a8b
76
75
  Waiting for approval… (expires in 10 minutes)
77
76
  ```
78
77
 
79
- Open the link signed in to your Zas account. Check that the fingerprint on the
80
- page is the one in your terminal, name the agent, and tick the channels it may
81
- use sending is the default, reading is a separate switch. The code is eight
82
- characters in two groups, and it is what you compare before you approve. The
83
- pairing is good for ten minutes; past that, run the command again.
84
-
85
- You can also start the flow from the coding agent with the `zas_pair` tool: the
86
- first call hands back the URL and the code, and a later call says whether the
87
- approval landed.
78
+ Signed in, name the agent and tick the channels it may use sending is the
79
+ default, reading is a separate switch and approve. Approval creates
80
+ nothing by itself: the page hands a one-time claim code to this terminal
81
+ over `127.0.0.1` (the port in the link), and the agent exists only once the
82
+ terminal claims with that code and the secret it holds. A link that reached
83
+ somebody else is approved on their machine, where nothing listens, and
84
+ expires with nothing created.
85
+
86
+ If the browser cannot reach the terminal — the link was opened on a phone,
87
+ or the browser refused the local connection — the page shows the code and
88
+ the terminal asks for it. Type it there and nowhere else: with that code,
89
+ another terminal that started a pairing could claim it. Pass `--no-open` or
90
+ set `ZAS_NO_OPEN=1` to keep the browser closed; the link is printed either
91
+ way. The pairing is good for ten minutes before approval and five after;
92
+ past that, run the command again.
93
+
94
+ You can also start the flow from the coding agent with the `zas_pair` tool:
95
+ the first call hands back the URL, a later call says whether the approval
96
+ landed, and if the page shows a code, a call with `code` claims with it.
88
97
 
89
98
  ## Why you can trust it
90
99
 
@@ -98,9 +107,11 @@ source, the sentence says so.
98
107
  account key, and the key-derivation service refuses account-key derivation to
99
108
  an agent *(server-side)*.
100
109
  - **You approve it, and you choose the channels.** Pairing never
101
- auto-approves. The approval page shows the harness, the host, the key
102
- fingerprint and the code, and you compare the code with your terminal before
103
- anything is granted.
110
+ auto-approves. The approval page shows the harness, the host and the key
111
+ fingerprint, and nothing exists until the terminal that started the
112
+ pairing claims it with a code only the approving page received
113
+ *(server-side)*. A pairing link sent to you by someone else creates
114
+ nothing on your account.
104
115
  - **It holds one key per granted channel, and no key for any other.** Each
105
116
  grant carries that channel's key sealed to the agent's X25519 public key. A
106
117
  channel you did not grant has no key here to decrypt with, and the server
@@ -193,7 +204,7 @@ stack trace.
193
204
  | Tool | What it does |
194
205
  | --- | --- |
195
206
  | `zas_status` | Says whether this machine is paired with a Zas account, and lists the owner's channels this agent may send to or read from. |
196
- | `zas_pair` | Pairs this machine with a Zas account. The first call returns a URL and a code for the owner to approve; a later call says whether they did. |
207
+ | `zas_pair` | Pairs this machine with a Zas account. The first call returns a URL for the owner to open; a later call says whether they approved. If the page shows a code, a call with `code` claims with it. |
197
208
  | `zas_send_file` | Sends a file from this machine into one of the owner's channels. Returns the item id, or a job id when the upload takes longer than a minute. |
198
209
  | `zas_send_note` | Sends a note — plain text, or a code snippet with its language — into one of the owner's channels. |
199
210
  | `zas_list_items` | Lists the most recent items in one of the owner's channels. Needs a grant that includes reading. |
@@ -232,8 +243,8 @@ crash mid-write cannot leave half a file behind:
232
243
 
233
244
  - `identity.json` — the agent uid, the owner uid, the name, and the two key
234
245
  pairs. Back it up like a private key, or delete it and pair again.
235
- - `pending.json` — a pairing that has not been approved yet. Removed on
236
- approval, and on a pairing that expired or was cancelled.
246
+ - `pending.json` — a pairing that has not been claimed yet. Removed on
247
+ completion, and on a pairing that expired or was cancelled.
237
248
  - `grants.json` — a one-minute cache of `GET /v1/agents/me`: which channels,
238
249
  and the sealed key for each. The channel name stays encrypted here.
239
250
  Disposable.
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import { createRequire as __cr } from "node:module"; const require = __cr(import
3
3
 
4
4
  // src/cli.ts
5
5
  import { realpathSync } from "node:fs";
6
+ import { createInterface } from "node:readline";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
9
 
@@ -76,6 +77,17 @@ var AGENT_TOKEN_TTL_SEC = 3600;
76
77
  var AGENT_LAST_SEEN_BUCKET_MS = 5 * 60 * 1e3;
77
78
  var AGENT_HOST_MAX = 60;
78
79
  var AGENT_CHALLENGE_DOMAIN = "ZAS-AGENT-CHALLENGE-V1";
80
+ var AGENT_PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
81
+ var AGENT_PAIRING_CODE_LENGTH = 8;
82
+ var AGENT_CLAIM_TTL_MS = 5 * 60 * 1e3;
83
+ function keepPairingCodeChars(raw) {
84
+ return raw.toUpperCase().split("").filter((c) => AGENT_PAIRING_CODE_ALPHABET.includes(c)).join("");
85
+ }
86
+ function normalizePairingCode(raw) {
87
+ if (typeof raw !== "string") return "";
88
+ const kept = keepPairingCodeChars(raw);
89
+ return kept.length === AGENT_PAIRING_CODE_LENGTH ? kept : "";
90
+ }
79
91
  var text = new TextEncoder();
80
92
  function field(bytes) {
81
93
  const length = new Uint8Array(4);
@@ -156,6 +168,11 @@ var SENTENCES = {
156
168
  duplicate: "That item is already in the channel.",
157
169
  pairing_expired: "The pairing expired. Run \u201Czas-agent pair\u201D again.",
158
170
  pairing_cancelled: "The owner cancelled the pairing.",
171
+ claim_mismatch: "The code does not match. Try again.",
172
+ pairing_not_approved: "Nobody has approved this pairing yet.",
173
+ pairing_claimed: "This pairing was already claimed. Pair again with \u201Czas-agent pair\u201D.",
174
+ agent_limit: "The account cannot take another agent. The owner deletes one from Settings \u2192 Agents.",
175
+ grant_limit: "The plan allows fewer channels per agent than this pairing grants.",
159
176
  feature_disabled: "Agents are not enabled for this account yet.",
160
177
  upload_failed: "The upload failed. Try again.",
161
178
  oprf_failed: "Zas did not answer correctly while preparing the file. Try again.",
@@ -278,12 +295,138 @@ function defaultEndpoints() {
278
295
  };
279
296
  }
280
297
 
298
+ // src/open.ts
299
+ import { spawn } from "node:child_process";
300
+ function openInBrowser(url, platform = process.platform) {
301
+ try {
302
+ const [command, args] = platform === "win32" ? ["cmd", ["/c", "start", "", url]] : platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
303
+ const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
304
+ child.on("error", () => void 0);
305
+ child.unref();
306
+ } catch {
307
+ }
308
+ }
309
+
281
310
  // src/pair.ts
282
311
  import { hostname } from "node:os";
283
312
 
313
+ // src/claim-listener.ts
314
+ import { createServer } from "node:http";
315
+ var BODY_MAX = 1024;
316
+ function readBody(req) {
317
+ return new Promise((resolve2) => {
318
+ let size = 0;
319
+ let resolved = false;
320
+ const chunks = [];
321
+ req.on("data", (chunk) => {
322
+ size += chunk.length;
323
+ if (size > BODY_MAX) {
324
+ if (!resolved) {
325
+ resolved = true;
326
+ resolve2(null);
327
+ }
328
+ return;
329
+ }
330
+ chunks.push(chunk);
331
+ });
332
+ req.on("end", () => {
333
+ if (!resolved) {
334
+ resolved = true;
335
+ resolve2(Buffer.concat(chunks).toString("utf8"));
336
+ }
337
+ });
338
+ req.on("error", () => {
339
+ if (!resolved) {
340
+ resolved = true;
341
+ resolve2(null);
342
+ }
343
+ });
344
+ });
345
+ }
346
+ async function startClaimListener(opts) {
347
+ let taken = false;
348
+ const server = createServer(async (req, res) => {
349
+ const origin = String(req.headers.origin ?? "");
350
+ const json = (status, body2) => {
351
+ res.writeHead(status, { "Content-Type": "application/json" });
352
+ res.end(JSON.stringify(body2));
353
+ };
354
+ if (req.url !== "/claim") {
355
+ json(404, { error: "not_found" });
356
+ return;
357
+ }
358
+ if (req.method === "OPTIONS") {
359
+ if (origin !== opts.webOrigin) {
360
+ res.writeHead(403);
361
+ res.end();
362
+ return;
363
+ }
364
+ res.writeHead(204, {
365
+ "Access-Control-Allow-Origin": origin,
366
+ "Access-Control-Allow-Methods": "POST",
367
+ "Access-Control-Allow-Headers": "content-type",
368
+ "Access-Control-Allow-Private-Network": "true",
369
+ "Access-Control-Max-Age": "300",
370
+ Vary: "Origin"
371
+ });
372
+ res.end();
373
+ return;
374
+ }
375
+ if (req.method !== "POST") {
376
+ json(404, { error: "not_found" });
377
+ return;
378
+ }
379
+ if (origin !== opts.webOrigin) {
380
+ json(403, { error: "forbidden" });
381
+ return;
382
+ }
383
+ res.setHeader("Access-Control-Allow-Origin", origin);
384
+ res.setHeader("Vary", "Origin");
385
+ if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
386
+ json(400, { error: "bad_request" });
387
+ return;
388
+ }
389
+ const raw = await readBody(req);
390
+ let body = {};
391
+ try {
392
+ body = raw === null ? {} : JSON.parse(raw);
393
+ } catch {
394
+ body = {};
395
+ }
396
+ if (typeof body !== "object" || body === null || body.pairing_id !== opts.pairingId || typeof body.code !== "string") {
397
+ json(400, { error: "bad_request" });
398
+ return;
399
+ }
400
+ if (taken) {
401
+ json(409, { error: "claim_taken" });
402
+ return;
403
+ }
404
+ taken = true;
405
+ const outcome = await opts.onCode(body.code);
406
+ if ("error" in outcome) json(outcome.status, { error: outcome.error });
407
+ else json(200, { status: "claimed" });
408
+ });
409
+ await new Promise((resolve2, reject) => {
410
+ server.once("error", reject);
411
+ server.listen(0, "127.0.0.1", () => {
412
+ server.off("error", reject);
413
+ resolve2();
414
+ });
415
+ });
416
+ const address = server.address();
417
+ return {
418
+ host: address.address,
419
+ port: address.port,
420
+ close: () => new Promise((resolve2) => {
421
+ server.closeAllConnections();
422
+ server.close(() => resolve2());
423
+ })
424
+ };
425
+ }
426
+
284
427
  // src/client.ts
285
428
  var REFRESH_MARGIN_MS = 5 * 60 * 1e3;
286
- async function readBody(res) {
429
+ async function readBody2(res) {
287
430
  const text2 = await res.text();
288
431
  if (!text2) return {};
289
432
  try {
@@ -302,7 +445,7 @@ function jsonInit(method, body, headers) {
302
445
  async function apiPublic(base, method, path, body, headers = {}, fetchImpl) {
303
446
  const call = fetchImpl ?? ((input, init) => fetch(input, init));
304
447
  const res = await call(`${base}${path}`, jsonInit(method, body, headers));
305
- const parsed = res.status === 204 ? void 0 : await readBody(res);
448
+ const parsed = res.status === 204 ? void 0 : await readBody2(res);
306
449
  if (!res.ok) throw errorFromResponse(res.status, parsed);
307
450
  return parsed;
308
451
  }
@@ -368,7 +511,7 @@ var ZasClient = class _ZasClient {
368
511
  }
369
512
  async api(method, path, body, headers = {}) {
370
513
  const res = await this.authedRetrying(`${this.identity.api_base}/v1${path}`, method, body, headers);
371
- const parsed = res.status === 204 ? void 0 : await readBody(res);
514
+ const parsed = res.status === 204 ? void 0 : await readBody2(res);
372
515
  if (!res.ok) throw errorFromResponse(res.status, parsed);
373
516
  return parsed;
374
517
  }
@@ -391,7 +534,7 @@ var ZasClient = class _ZasClient {
391
534
  }
392
535
  async oprfEvaluate(blinded) {
393
536
  const res = await this.authedRetrying(`${this.identity.oprf_base}/evaluate`, "POST", { blinded }, {});
394
- const parsed = await readBody(res);
537
+ const parsed = await readBody2(res);
395
538
  if (!res.ok) throw errorFromResponse(res.status, parsed);
396
539
  return parsed.evaluated ?? [];
397
540
  }
@@ -402,7 +545,7 @@ var ZasClient = class _ZasClient {
402
545
  const base = _ZasClient.firestoreBase(this.identity.firestore_project);
403
546
  const url = `${base}${parentPath ? `/${parentPath}` : ""}:runQuery`;
404
547
  const res = await this.authed(url, "POST", { structuredQuery: query }, {});
405
- const parsed = await readBody(res);
548
+ const parsed = await readBody2(res);
406
549
  if (!res.ok) throw errorFromResponse(res.status, parsed);
407
550
  if (!Array.isArray(parsed)) return [];
408
551
  return parsed.filter((row) => !!row && typeof row === "object" && "document" in row).map((row) => row.document);
@@ -427,10 +570,10 @@ function packageName() {
427
570
  return true ? "zas-agent" : "zas-agent";
428
571
  }
429
572
  function claudeSnippet(profile) {
430
- return `claude mcp add zas -- npx -y ${packageName()} --profile ${profile}`;
573
+ return `claude mcp add zas "--" npx -y ${packageName()} --profile ${profile}`;
431
574
  }
432
575
  function codexSnippet(profile) {
433
- return `codex mcp add zas -- npx -y ${packageName()} --profile ${profile}`;
576
+ return `codex mcp add zas "--" npx -y ${packageName()} --profile ${profile}`;
434
577
  }
435
578
  function kindForProfile(profile) {
436
579
  if (profile === "claude-code") return "claude_code";
@@ -440,13 +583,23 @@ function kindForProfile(profile) {
440
583
 
441
584
  // src/pair.ts
442
585
  var POLL_INTERVAL_MS = 2e3;
443
- function pairUrl(webBase, pairingId) {
444
- return `${webBase}/agents/pair?p=${pairingId}`;
586
+ var NO_TTY_LINE = "This terminal cannot take a code. If the page shows one, run zas-agent pair again from an interactive terminal.";
587
+ var CODE_SHAPE_LINE = "The code is eight letters and digits, like ABCD-EFGH.";
588
+ function pairUrl(webBase, pairingId, port) {
589
+ const link = `${webBase}/agents/pair?p=${pairingId}`;
590
+ return port ? `${link}#port=${port}` : link;
445
591
  }
446
- function formatCode(code) {
447
- return `${code.slice(0, 4)}-${code.slice(4)}`;
592
+ function webOriginOf(webBase) {
593
+ return new URL(webBase).origin;
448
594
  }
449
595
  var defaultSleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
596
+ function deferred() {
597
+ let resolve2;
598
+ const promise = new Promise((r) => {
599
+ resolve2 = r;
600
+ });
601
+ return { promise, resolve: resolve2 };
602
+ }
450
603
  async function runPair(opts) {
451
604
  const sleep2 = opts.sleep ?? defaultSleep;
452
605
  const now = opts.now ?? (() => Date.now());
@@ -463,67 +616,152 @@ async function runPair(opts) {
463
616
  opts.apiBase,
464
617
  "POST",
465
618
  "/v1/agents/pairings",
466
- { kind: opts.kind, host, x25519_public: keys.x25519_public, p256_public: keys.p256_public },
619
+ { kind: opts.kind, host, x25519_public: keys.x25519_public, p256_public: keys.p256_public, claim: true },
467
620
  {},
468
621
  opts.fetch
469
622
  );
470
- const pending = {
471
- version: 1,
472
- profile: opts.profile,
473
- pairing_id: created.pairing_id,
474
- poll_secret: created.poll_secret,
475
- code: created.code,
476
- fingerprint: created.fingerprint,
477
- expires_at: created.expires_at,
478
- kind: opts.kind,
479
- host,
480
- ...keys,
481
- ...endpoints,
482
- created_at: now()
623
+ let claimed = null;
624
+ let inFlight = null;
625
+ const wake = deferred();
626
+ const claimOnce = (code) => {
627
+ if (claimed) return Promise.resolve({ error: "pairing_claimed", status: 409 });
628
+ if (inFlight) return inFlight;
629
+ inFlight = (async () => {
630
+ try {
631
+ const answer = await apiPublic(
632
+ opts.apiBase,
633
+ "POST",
634
+ `/v1/agents/pairings/${created.pairing_id}/claim`,
635
+ { code },
636
+ { "X-Zas-Poll-Secret": created.poll_secret },
637
+ opts.fetch
638
+ );
639
+ claimed = { agent_uid: answer.agent_uid, owner_uid: answer.owner_uid };
640
+ wake.resolve();
641
+ return { status: "claimed" };
642
+ } catch (err) {
643
+ if (err instanceof ZasError) return { error: err.code, status: err.status };
644
+ return { error: "network", status: 502 };
645
+ } finally {
646
+ inFlight = null;
647
+ }
648
+ })();
649
+ return inFlight;
483
650
  };
484
- savePending(opts.profile, pending);
485
- const minutesLeft = Math.max(0, Math.round((created.expires_at - now()) / 6e4));
486
- opts.log(
487
- [
488
- "Open this page signed in to your Zas account:",
489
- ` ${pairUrl(opts.webBase, created.pairing_id)}`,
490
- `Code: ${formatCode(created.code)}`,
491
- `Fingerprint: ${agentFingerprintShort(created.fingerprint)}`,
492
- `Waiting for approval\u2026 (expires in ${minutesLeft} minutes)`
493
- ].join("\n")
494
- );
495
- let approved = null;
496
- for (; ; ) {
497
- let answer;
651
+ let listener = null;
652
+ if (opts.listen !== false) {
498
653
  try {
499
- answer = await apiPublic(
500
- opts.apiBase,
501
- "POST",
502
- `/v1/agents/pairings/${created.pairing_id}/poll`,
503
- void 0,
504
- { "X-Zas-Poll-Secret": created.poll_secret },
505
- opts.fetch
506
- );
507
- } catch (err) {
508
- if (!(err instanceof ZasError) || err.code !== "rate_limited") throw err;
509
- if (now() >= created.expires_at) throw new ZasError("pairing_expired", 410);
510
- await sleep2(err.retryAfterMs ?? 1e4);
511
- continue;
512
- }
513
- if (answer.status === "approved") {
514
- approved = { agent_uid: answer.agent_uid, owner_uid: answer.owner_uid };
515
- break;
654
+ listener = await startClaimListener({
655
+ webOrigin: webOriginOf(opts.webBase),
656
+ pairingId: created.pairing_id,
657
+ onCode: (code) => claimOnce(normalizePairingCode(code) || code)
658
+ });
659
+ } catch {
660
+ listener = null;
516
661
  }
517
- if (answer.status === "expired" || answer.status === "cancelled") {
518
- clearPending(opts.profile);
519
- throw new ZasError(`pairing_${answer.status}`, 410);
662
+ }
663
+ let askFailed = null;
664
+ try {
665
+ const port = listener?.port;
666
+ const link = pairUrl(opts.webBase, created.pairing_id, port);
667
+ const pending = {
668
+ version: 1,
669
+ profile: opts.profile,
670
+ pairing_id: created.pairing_id,
671
+ poll_secret: created.poll_secret,
672
+ fingerprint: created.fingerprint,
673
+ expires_at: created.expires_at,
674
+ kind: opts.kind,
675
+ host,
676
+ ...keys,
677
+ ...endpoints,
678
+ created_at: now(),
679
+ protocol: 2,
680
+ ...port ? { port } : {}
681
+ };
682
+ savePending(opts.profile, pending);
683
+ const minutesLeft = Math.max(0, Math.round((created.expires_at - now()) / 6e4));
684
+ opts.log(
685
+ [
686
+ "Open this page signed in to your Zas account:",
687
+ ` ${link}`,
688
+ `Fingerprint: ${agentFingerprintShort(created.fingerprint)}`,
689
+ `Waiting for approval\u2026 (expires in ${minutesLeft} minutes)`
690
+ ].join("\n")
691
+ );
692
+ opts.open?.(link);
693
+ let asking = false;
694
+ let toldNoTty = false;
695
+ const askLoop = async () => {
696
+ try {
697
+ while (!claimed) {
698
+ const code = normalizePairingCode(await opts.askCode());
699
+ if (!code) {
700
+ opts.log(CODE_SHAPE_LINE);
701
+ continue;
702
+ }
703
+ const outcome = await claimOnce(code);
704
+ if (!("error" in outcome) || outcome.error === "pairing_claimed") return;
705
+ if (outcome.error === "claim_mismatch") {
706
+ opts.log(humanSentence(new ZasError("claim_mismatch", 403)));
707
+ continue;
708
+ }
709
+ throw new ZasError(outcome.error, outcome.status);
710
+ }
711
+ } catch (e) {
712
+ askFailed = e;
713
+ wake.resolve();
714
+ }
715
+ };
716
+ for (; ; ) {
717
+ if (claimed) break;
718
+ if (askFailed) throw askFailed;
719
+ let answer;
720
+ try {
721
+ answer = await apiPublic(
722
+ opts.apiBase,
723
+ "POST",
724
+ `/v1/agents/pairings/${created.pairing_id}/poll`,
725
+ void 0,
726
+ { "X-Zas-Poll-Secret": created.poll_secret },
727
+ opts.fetch
728
+ );
729
+ } catch (err) {
730
+ if (!(err instanceof ZasError) || err.code !== "rate_limited") throw err;
731
+ if (now() >= created.expires_at) throw new ZasError("pairing_expired", 410);
732
+ await Promise.race([sleep2(err.retryAfterMs ?? 1e4), wake.promise]);
733
+ continue;
734
+ }
735
+ if (claimed) break;
736
+ if (answer.status === "claimed") {
737
+ claimed = { agent_uid: answer.agent_uid, owner_uid: answer.owner_uid };
738
+ break;
739
+ }
740
+ if (answer.status === "expired" || answer.status === "cancelled") {
741
+ clearPending(opts.profile);
742
+ throw new ZasError(`pairing_${answer.status}`, 410);
743
+ }
744
+ if (answer.status === "approved") {
745
+ if (opts.askCode && !asking) {
746
+ asking = true;
747
+ void askLoop();
748
+ }
749
+ if (!opts.askCode && !toldNoTty) {
750
+ toldNoTty = true;
751
+ opts.log(NO_TTY_LINE);
752
+ }
753
+ }
754
+ await Promise.race([sleep2(POLL_INTERVAL_MS), wake.promise]);
520
755
  }
521
- await sleep2(POLL_INTERVAL_MS);
756
+ } finally {
757
+ await listener?.close();
522
758
  }
759
+ if (askFailed) throw askFailed;
760
+ if (!claimed) throw new ZasError("pairing_expired", 410);
523
761
  const baseIdentity = {
524
762
  version: 1,
525
- agent_uid: approved.agent_uid,
526
- owner_uid: approved.owner_uid,
763
+ agent_uid: claimed.agent_uid,
764
+ owner_uid: claimed.owner_uid,
527
765
  name: "",
528
766
  kind: opts.kind,
529
767
  host,
@@ -1631,7 +1869,7 @@ async function sendNote(ctx, input) {
1631
1869
 
1632
1870
  // src/server.ts
1633
1871
  function agentVersion() {
1634
- return true ? "0.1.1" : "0.0.0-dev";
1872
+ return true ? "0.2.1" : "0.0.0-dev";
1635
1873
  }
1636
1874
  var AGENT_CUE = " The owner sees every item this agent sends with the >_ agent mark and this agent's name, on every device.";
1637
1875
  var PAIR_ANNOUNCE_MS = 15e3;
@@ -1703,10 +1941,14 @@ function buildServer(profile, deps = {}) {
1703
1941
  });
1704
1942
  let pairing = null;
1705
1943
  server.registerTool("zas_pair", {
1706
- description: "Pair this machine with a Zas account. The first call returns a URL and a code for the owner to approve; a later call says whether they did." + AGENT_CUE
1707
- }, async () => {
1944
+ description: "Pair this machine with a Zas account. The first call returns a URL for the owner to open; a later call says whether they approved. If the page shows a code, call again with `code`." + AGENT_CUE,
1945
+ inputSchema: {
1946
+ code: z.string().optional().describe("The code the pairing page shows when the browser could not reach this machine.")
1947
+ }
1948
+ }, async (input) => {
1708
1949
  if (!pairing) {
1709
- const state = { logs: [], status: "running" };
1950
+ if (input.code !== void 0) return failed(new ZasError("pairing_not_approved", 409));
1951
+ const state = { logs: [], status: "running", ask: null };
1710
1952
  pairing = state;
1711
1953
  (deps.runPair ?? runPair)({
1712
1954
  profile,
@@ -1717,7 +1959,13 @@ function buildServer(profile, deps = {}) {
1717
1959
  apiBase: defaultEndpoints().api_base,
1718
1960
  log: (line) => {
1719
1961
  state.logs.push(line);
1720
- }
1962
+ },
1963
+ // The person cannot type into this process; the coding agent relays
1964
+ // the code as an argument, and this promise is what it answers.
1965
+ askCode: () => new Promise((resolve2) => {
1966
+ state.ask = resolve2;
1967
+ }),
1968
+ ...process.env.ZAS_NO_OPEN === "1" ? {} : { open: openInBrowser }
1721
1969
  }).then(
1722
1970
  (identity) => {
1723
1971
  state.status = "paired";
@@ -1744,6 +1992,25 @@ function buildServer(profile, deps = {}) {
1744
1992
  pairing = null;
1745
1993
  return failed(error);
1746
1994
  }
1995
+ if (input.code !== void 0) {
1996
+ const state = pairing;
1997
+ const ask = state.ask;
1998
+ if (!ask) return failed(new ZasError("pairing_not_approved", 409));
1999
+ state.ask = null;
2000
+ ask(normalizePairingCode(input.code) || input.code);
2001
+ const until = Date.now() + (deps.announceMs ?? PAIR_ANNOUNCE_MS);
2002
+ while (state.status === "running" && state.ask === null && Date.now() < until) {
2003
+ await delay(10);
2004
+ }
2005
+ if (state.status === "paired") return text2(`paired as ${state.identity?.name ?? ""}`.trim());
2006
+ if (state.status === "failed") {
2007
+ const error = state.error;
2008
+ pairing = null;
2009
+ return failed(error);
2010
+ }
2011
+ if (state.ask !== null) return failed(new ZasError("claim_mismatch", 403));
2012
+ return text2(["pending", ...state.logs].join("\n"));
2013
+ }
1747
2014
  return text2(["pending", ...pairing.logs].join("\n"));
1748
2015
  });
1749
2016
  server.registerTool("zas_send_file", {
@@ -1846,7 +2113,8 @@ function parseArgs(argv) {
1846
2113
  const kind = take();
1847
2114
  if (!isAgentKind(kind)) return { ...parsed, command: "help", unknown: `--kind ${kind}` };
1848
2115
  parsed.kind = kind;
1849
- } else if (name === "--host") parsed.host = take() || void 0;
2116
+ } else if (name === "--no-open") parsed.noOpen = true;
2117
+ else if (name === "--host") parsed.host = take() || void 0;
1850
2118
  else return { ...parsed, command: "help", unknown: arg };
1851
2119
  }
1852
2120
  return parsed;
@@ -1856,7 +2124,7 @@ var USAGE = [
1856
2124
  "",
1857
2125
  " zas-agent [--profile <name>] serve the MCP tools over stdio",
1858
2126
  " zas-agent pair [--profile <name>] pair this machine with a Zas account",
1859
- " [--kind claude_code|codex|other] [--host <name>]",
2127
+ " [--kind claude_code|codex|other] [--host <name>] [--no-open]",
1860
2128
  " zas-agent --version"
1861
2129
  ].join("\n");
1862
2130
  async function main(argv, log = (l) => console.error(l)) {
@@ -1875,6 +2143,14 @@ async function main(argv, log = (l) => console.error(l)) {
1875
2143
  return args.unknown ? 2 : 0;
1876
2144
  }
1877
2145
  if (args.command === "pair") {
2146
+ let rl = null;
2147
+ const askCode = process.stdin.isTTY ? () => {
2148
+ rl ??= createInterface({ input: process.stdin, output: process.stderr });
2149
+ return new Promise((resolve2) => {
2150
+ rl.question("Type the code shown in the browser: ", resolve2);
2151
+ });
2152
+ } : void 0;
2153
+ const noOpen = args.noOpen === true || process.env.ZAS_NO_OPEN === "1";
1878
2154
  try {
1879
2155
  await runPair({
1880
2156
  profile: args.profile,
@@ -1882,12 +2158,16 @@ async function main(argv, log = (l) => console.error(l)) {
1882
2158
  ...args.host !== void 0 ? { host: args.host } : {},
1883
2159
  webBase: process.env.ZAS_WEB_BASE || "https://zas.red",
1884
2160
  apiBase: defaultEndpoints().api_base,
1885
- log
2161
+ log,
2162
+ ...askCode ? { askCode } : {},
2163
+ ...noOpen ? {} : { open: openInBrowser }
1886
2164
  });
1887
2165
  } catch (e) {
1888
2166
  const err = e instanceof ZasError ? e : new ZasError("internal", 0, String(e));
1889
2167
  log(humanSentence(err));
1890
2168
  return 1;
2169
+ } finally {
2170
+ rl?.close();
1891
2171
  }
1892
2172
  return 0;
1893
2173
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zas-agent",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "MCP server that lets a coding agent send files and notes into your Zas channels.",
5
5
  "type": "module",
6
6
  "license": "MIT",