sessionpipe 0.2.0 → 0.4.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
@@ -33,7 +33,7 @@ enroll at this terminal; the receiver can queue a message but can never forge on
33
33
  ([spec](https://sessionpipe.org/protocol/control/)).
34
34
 
35
35
  ```sh
36
- sessionpipe control pair https://receiver.example --folder ~/code # confirm with your passkey; installs the daemon
36
+ sessionpipe control pair spacesheep.dev # one link: approve with your passkey; installs the daemon (this folder by default)
37
37
  sessionpipe control status # receivers, parked waiters, sessions mid-turn, open permission prompts
38
38
  sessionpipe control keys [remove <id>]
39
39
  sessionpipe control off # tell the receiver, forget its token and keys
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // sessionpipe 0.2.0 · Apache-2.0 · https://sessionpipe.org
2
+ // sessionpipe 0.4.0 · Apache-2.0 · https://sessionpipe.org
3
3
  var __defProp = Object.defineProperty;
4
4
  var __export = (target, all) => {
5
5
  for (var name in all)
@@ -723,7 +723,7 @@ var OPTS = {
723
723
  timeoutFor: (e) => e === "PermissionRequest" ? 130 : void 0
724
724
  };
725
725
  var DESKTOP_ID = /^local_[A-Za-z0-9-]{1,64}$/;
726
- function claudeDirs(env = process.env, home = HOME) {
726
+ function claudeDirs(env = process.env, home = env.HOME || HOME) {
727
727
  const out2 = [];
728
728
  const add = (d) => {
729
729
  if (!d) return;
@@ -1946,7 +1946,8 @@ function parseCaps(help2) {
1946
1946
  hasMode: !!pm,
1947
1947
  modes: pm ? [...pm.matchAll(/"([A-Za-z]+)"/g)].map((m) => m[1]) : [],
1948
1948
  promptsNone: !!pp && /"none"/.test(pp),
1949
- fork: !!optionBlock(help2, "--fork-session")
1949
+ fork: !!optionBlock(help2, "--fork-session"),
1950
+ stream: !!optionBlock(help2, "--include-partial-messages")
1950
1951
  };
1951
1952
  }
1952
1953
  function modeFlags(mode, caps) {
@@ -2148,7 +2149,26 @@ function runClaude(bin, args2, o) {
2148
2149
  }
2149
2150
  }
2150
2151
  };
2152
+ let partial = "";
2151
2153
  child.stdout?.on("data", (b) => {
2154
+ if (o.onLine) {
2155
+ const chunk = partial + b.toString("utf8");
2156
+ const lines = chunk.split("\n");
2157
+ partial = lines.pop() ?? "";
2158
+ for (const line of lines) {
2159
+ if (!line.trim()) continue;
2160
+ try {
2161
+ o.onLine(line);
2162
+ } catch {
2163
+ }
2164
+ if (line.includes('"type":"result"')) {
2165
+ out2 = [Buffer.from(line)];
2166
+ outLen = line.length;
2167
+ }
2168
+ }
2169
+ if (partial.length > OUT_CAP) partial = "";
2170
+ return;
2171
+ }
2152
2172
  if (tooBig) return;
2153
2173
  if (outLen + b.length > OUT_CAP) {
2154
2174
  tooBig = true;
@@ -2182,7 +2202,20 @@ function runClaude(bin, args2, o) {
2182
2202
  };
2183
2203
  child.on("error", (e) => finish({ error: e.message }));
2184
2204
  child.on("exit", (code, signal) => setTimeout(() => finish(result(code, signal)), 2e3).unref());
2185
- child.on("close", (code, signal) => finish(result(code, signal)));
2205
+ child.on("close", (code, signal) => {
2206
+ if (o.onLine && partial.trim()) {
2207
+ try {
2208
+ o.onLine(partial);
2209
+ } catch {
2210
+ }
2211
+ if (partial.includes('"type":"result"')) {
2212
+ out2 = [Buffer.from(partial)];
2213
+ outLen = partial.length;
2214
+ }
2215
+ partial = "";
2216
+ }
2217
+ finish(result(code, signal));
2218
+ });
2186
2219
  });
2187
2220
  }
2188
2221
  function parseResult(stdout) {
@@ -2850,13 +2883,14 @@ var SKEW_MS = 5 * 6e4;
2850
2883
  var MACHINE_MAX_AGE_MS = 24 * 36e5;
2851
2884
  var RECEIVER_MAX_AGE_MS = 10 * 6e4;
2852
2885
  var NONCE_TTL_MS = 25 * 36e5;
2853
- var CMD_MAX = 12e3;
2854
- var TEXT_MAX = 4e3;
2886
+ var CMD_MAX = 48e3;
2887
+ var TEXT_MAX = 2e4;
2855
2888
  var NOTE_MAX = 2e3;
2856
2889
  var GRANT_STR_MAX = 2e3;
2857
2890
  var COMMAND_TYPE = "sessionpipe.control";
2858
2891
  var GRANT_TYPE = "sessionpipe.grant";
2859
- var CONTROL_KINDS = ["prompt", "permission.answer", "cancel", "start"];
2892
+ var CONTROL_KINDS = ["prompt", "permission.answer", "cancel", "start", "key.add"];
2893
+ var KEY_STORE_SESSION = "sessionpipe:keys";
2860
2894
  var ES256 = -7;
2861
2895
  var RS256 = -257;
2862
2896
  var NONCE_RE = /^[A-Za-z0-9_-]{22,64}$/;
@@ -3066,7 +3100,20 @@ function parseCommand(str) {
3066
3100
  if (has2("text") || has2("for") || has2("decision") || has2("cwd"))
3067
3101
  return bad("bad_fields", "a cancel carries nothing");
3068
3102
  break;
3103
+ case "key.add": {
3104
+ if (c.session !== KEY_STORE_SESSION) return bad("bad_session", `a key.add names ${KEY_STORE_SESSION}`);
3105
+ const k = c.key;
3106
+ const b64 = (v, min, max) => typeof v === "string" && v.length >= min && v.length <= max && /^[A-Za-z0-9_-]+$/.test(v);
3107
+ if (!k || typeof k !== "object" || !b64(k.id, 16, 1400) || k.alg !== -7 && k.alg !== -257 || !b64(k.spki, 40, 2e3))
3108
+ return bad("bad_fields", "a key.add carries the key: id, alg (-7 or -257) and spki");
3109
+ if (has2("name") && (typeof c.name !== "string" || !c.name.trim() || c.name.length > 80))
3110
+ return bad("bad_fields", "a device name is text, at most 80 characters");
3111
+ if (has2("text") || has2("for") || has2("decision") || has2("cwd"))
3112
+ return bad("bad_fields", "a key.add carries the key and a name");
3113
+ break;
3114
+ }
3069
3115
  }
3116
+ if (c.kind !== "key.add" && (has2("key") || has2("name"))) return bad("bad_fields", "only a key.add carries a key");
3070
3117
  if (has2("note") && (typeof c.note !== "string" || c.note.length > NOTE_MAX))
3071
3118
  return bad("bad_fields", `a note is text, at most ${NOTE_MAX} characters`);
3072
3119
  return { ok: true, cmd: c };
@@ -3217,6 +3264,7 @@ async function awaitAnswer(file, from, o) {
3217
3264
  }
3218
3265
  const a = answerIn(lines);
3219
3266
  if (a.done) return a.text || null;
3267
+ if (a.text) o.onText?.(a.text);
3220
3268
  }
3221
3269
  await sleep2(o.pollMs ?? 500);
3222
3270
  }
@@ -3392,6 +3440,83 @@ function readState(file) {
3392
3440
  }
3393
3441
  var writeState = (file, s) => writePrivate(file, s);
3394
3442
 
3443
+ // src/control/stream.ts
3444
+ function streamJsonPiece(line) {
3445
+ let j;
3446
+ try {
3447
+ j = JSON.parse(line);
3448
+ } catch {
3449
+ return null;
3450
+ }
3451
+ if (j.type !== "stream_event" || !j.event) return null;
3452
+ if (j.parent_tool_use_id) return null;
3453
+ const e = j.event;
3454
+ if (e.type === "content_block_delta" && e.delta?.type === "text_delta" && typeof e.delta.text === "string")
3455
+ return { text: e.delta.text };
3456
+ if (e.type === "message_start") return { boundary: true };
3457
+ return null;
3458
+ }
3459
+ var AnswerStream = class {
3460
+ constructor(send, everyMs = 400) {
3461
+ this.send = send;
3462
+ this.everyMs = everyMs;
3463
+ }
3464
+ send;
3465
+ everyMs;
3466
+ text = "";
3467
+ sent = "";
3468
+ seq = 0;
3469
+ timer = null;
3470
+ inFlight = false;
3471
+ closed = false;
3472
+ /** A stream-json line from a headless run. */
3473
+ feed(line) {
3474
+ const p = streamJsonPiece(line);
3475
+ if (!p) return;
3476
+ if (p.boundary) {
3477
+ if (this.text && !this.text.endsWith("\n\n")) this.text += "\n\n";
3478
+ return;
3479
+ }
3480
+ if (p.text) {
3481
+ this.text += p.text;
3482
+ this.schedule();
3483
+ }
3484
+ }
3485
+ /** The whole answer so far (a live session's transcript). */
3486
+ set(text) {
3487
+ if (text === this.text) return;
3488
+ this.text = text;
3489
+ this.schedule();
3490
+ }
3491
+ /** No more progress: the final ack follows. */
3492
+ close() {
3493
+ this.closed = true;
3494
+ if (this.timer) clearTimeout(this.timer);
3495
+ this.timer = null;
3496
+ }
3497
+ schedule() {
3498
+ if (this.closed || this.timer || this.inFlight) return;
3499
+ this.timer = setTimeout(() => {
3500
+ this.timer = null;
3501
+ void this.flush();
3502
+ }, this.everyMs);
3503
+ this.timer.unref?.();
3504
+ }
3505
+ async flush() {
3506
+ const t = this.text.trim();
3507
+ if (this.closed || !t || t === this.sent) return;
3508
+ this.inFlight = true;
3509
+ this.sent = t;
3510
+ try {
3511
+ await this.send(t, ++this.seq);
3512
+ } catch {
3513
+ } finally {
3514
+ this.inFlight = false;
3515
+ }
3516
+ if (this.text.trim() !== this.sent) this.schedule();
3517
+ }
3518
+ };
3519
+
3395
3520
  // src/control/daemon.ts
3396
3521
  var {
3397
3522
  claudeEnv: claudeEnv2,
@@ -3404,7 +3529,7 @@ var {
3404
3529
  recentlyWritten: recentlyWritten2,
3405
3530
  sessionFolder: sessionFolder2
3406
3531
  } = claude_code_control_exports;
3407
- var VERSION = true ? "0.2.0" : "0.0.0";
3532
+ var VERSION = true ? "0.4.0" : "0.0.0";
3408
3533
  var MAX_REPLY = 2e4;
3409
3534
  var HELLO_EVERY_MS = 10 * 6e4;
3410
3535
  var TURN_FALLBACK_MS = 30 * 6e4;
@@ -3418,6 +3543,12 @@ var CLOSES_ATTENTION = /* @__PURE__ */ new Set([
3418
3543
  "UserPromptSubmit",
3419
3544
  "SessionEnd"
3420
3545
  ]);
3546
+ function sessionToolsFrom(v) {
3547
+ if (!Array.isArray(v)) return [];
3548
+ return [
3549
+ ...new Set(v.filter((t) => typeof t === "string" && /^mcp__[A-Za-z0-9_-]{1,64}$/.test(t)))
3550
+ ].slice(0, 8);
3551
+ }
3421
3552
  var LIMITS = { startConcurrent: 2, startPerHour: 10, headless: 3 };
3422
3553
  var ControlDaemon = class {
3423
3554
  constructor(cfg, dir, sock, deps) {
@@ -3449,6 +3580,8 @@ var ControlDaemon = class {
3449
3580
  helloTimer = null;
3450
3581
  helloSoon = null;
3451
3582
  aborts = /* @__PURE__ */ new Set();
3583
+ /** Per receiver: the MCP servers its sessions may use unattended (its well-known). */
3584
+ sessionTools = /* @__PURE__ */ new Map();
3452
3585
  startsRunning = 0;
3453
3586
  startTimes = [];
3454
3587
  procs = 0;
@@ -3729,7 +3862,31 @@ ${IN_PLACE_ANSWER}`, id: p.msgId });
3729
3862
  }, 1500);
3730
3863
  this.helloSoon.unref();
3731
3864
  }
3865
+ /** Read each receiver's well-known for its session tools (at start, then with every hello). */
3866
+ async refreshSessionTools() {
3867
+ for (const r of this.cfg.receivers) {
3868
+ try {
3869
+ const res = await this.deps.fetch(`${r.url.replace(/\/$/, "")}/.well-known/sessionpipe`, {
3870
+ signal: AbortSignal.timeout(8e3)
3871
+ });
3872
+ if (!res.ok) continue;
3873
+ const wk = await res.json();
3874
+ const tools = sessionToolsFrom(wk.control?.session_tools);
3875
+ const key = r.control + r.machine;
3876
+ if (JSON.stringify(tools) !== JSON.stringify(this.sessionTools.get(key) ?? []))
3877
+ this.deps.log(
3878
+ `${r.url}: sessions it messages may use ${tools.length ? tools.join(", ") : "nothing extra"} without asking`
3879
+ );
3880
+ this.sessionTools.set(key, tools);
3881
+ } catch {
3882
+ }
3883
+ }
3884
+ }
3885
+ allowedFor(r) {
3886
+ return this.sessionTools.get(r.control + r.machine) ?? [];
3887
+ }
3732
3888
  async helloAll() {
3889
+ void this.refreshSessionTools();
3733
3890
  const waiting = [...this.live].filter(([, l]) => l.waiter).map(([k]) => k);
3734
3891
  const modes = ["waiter", "turn", "resume", "fork", ...this.deps.sdk ? ["sdk"] : []];
3735
3892
  for (const r of this.cfg.receivers) {
@@ -3825,6 +3982,7 @@ ${IN_PLACE_ANSWER}`, id: p.msgId });
3825
3982
  return this.finish(r, m.id, { outcome: "refused", code: v.code, detail: v.why });
3826
3983
  }
3827
3984
  const cmd2 = v.cmd;
3985
+ if (cmd2.kind === "key.add") return this.addKey(r, m.id, cmd2, sent);
3828
3986
  const ref = parseSessionRef(cmd2.session);
3829
3987
  const kp = kindPath(cmd2.kind, ref.harness);
3830
3988
  if (typeof kp === "object") return this.finish(r, m.id, { outcome: "unsupported", detail: kp.unsupported });
@@ -4015,15 +4173,26 @@ ${IN_PLACE_ANSWER}`, id: p.msgId });
4015
4173
  await this.serial(ref.id, async () => {
4016
4174
  const target = this.state.copies[ref.id] ?? ref.id;
4017
4175
  const resume = mode === "fork" ? ["--resume", target, "--fork-session"] : ["--resume", target];
4018
- const args2 = ["-p", "--output-format", "json", ...mf.args, ...resume, frame(cmd2.text, r.url)];
4176
+ const allow = this.allowedFor(r);
4177
+ const args2 = [
4178
+ "-p",
4179
+ ...this.outputArgs(claude.caps),
4180
+ ...mf.args,
4181
+ ...allow.length ? [`--allowedTools=${allow.join(",")}`] : [],
4182
+ ...resume,
4183
+ frame(cmd2.text, r.url)
4184
+ ];
4019
4185
  this.deps.log(`${id.slice(-8)}: claude -p ${[...mf.args, ...resume].join(" ")} in ${place.folder}`);
4186
+ const stream = this.answerStream(r, id);
4020
4187
  const res = await this.slot(
4021
4188
  () => this.deps.run(claude.bin, args2, {
4022
4189
  cwd: place.folder,
4023
4190
  env: claudeEnv2(place.transcript?.configDir, this.deps.env),
4024
- timeoutMs: JOB_TIMEOUT_MS2
4191
+ timeoutMs: JOB_TIMEOUT_MS2,
4192
+ ...claude.caps.stream ? { onLine: (l) => stream.feed(l) } : {}
4025
4193
  })
4026
4194
  );
4195
+ stream.close();
4027
4196
  this.lastEnd.set(target, this.deps.now());
4028
4197
  const out2 = this.outcome(res);
4029
4198
  if ("failed" in out2) return this.finish(r, id, { outcome: "failed", detail: out2.failed });
@@ -4061,6 +4230,33 @@ ${IN_PLACE_ANSWER}`, id: p.msgId });
4061
4230
  const text = typeof j.result === "string" && j.result.trim() ? j.result : "(Claude Code finished without a written reply.)";
4062
4231
  return { reply: text + tail2, ...typeof j.session_id === "string" ? { session: j.session_id } : {} };
4063
4232
  }
4233
+ addKey(r, id, cmd2, sent) {
4234
+ const k = cmd2.key;
4235
+ const name = typeof cmd2.name === "string" ? cmd2.name.trim().slice(0, 80) : void 0;
4236
+ const rec = this.cfg.receivers.find((x) => x.control === r.control && x.machine === r.machine) ?? r;
4237
+ if (rec.keys.some((x) => x.id === k.id))
4238
+ return this.finish(r, id, { outcome: "delivered", detail: "that device was already trusted" }, sent);
4239
+ rec.keys.push({
4240
+ id: k.id,
4241
+ alg: k.alg,
4242
+ spki: k.spki,
4243
+ ...name ? { name } : {},
4244
+ added_at: new Date(this.deps.now()).toISOString()
4245
+ });
4246
+ try {
4247
+ writeControl(this.cfg, this.deps.env);
4248
+ } catch (e) {
4249
+ rec.keys = rec.keys.filter((x) => x.id !== k.id);
4250
+ return this.finish(r, id, {
4251
+ outcome: "failed",
4252
+ detail: `couldn't save the key: ${String(e?.message || e)}`
4253
+ });
4254
+ }
4255
+ this.deps.log(
4256
+ `trusted a new device${name ? ` (${name})` : ""} on ${r.url}: ${k.id.slice(0, 12)}\u2026, vouched for by a key it already trusted`
4257
+ );
4258
+ this.finish(r, id, { outcome: "delivered", detail: `trusted ${name || "a new device"}` }, sent);
4259
+ }
4064
4260
  /** Where the session's transcript ends right now: its answer starts after this. */
4065
4261
  answerFrom(session) {
4066
4262
  const ref = parseSessionRef(session);
@@ -4075,14 +4271,38 @@ ${IN_PLACE_ANSWER}`, id: p.msgId });
4075
4271
  followAnswer(r, id, mode, session, from, sent) {
4076
4272
  this.deps.log(`${id.slice(-8)}: in ${session.slice(0, 24)} via ${mode} ${this.deps.now() - sent} ms after send`);
4077
4273
  this.finish(r, id, { outcome: "taken" });
4274
+ const stream = this.answerStream(r, id);
4078
4275
  void (async () => {
4079
4276
  const answer = from ? await awaitAnswer(from.file, from.offset, {
4080
4277
  timeoutMs: this.deps.answerWaitMs ?? ANSWER_WAIT_MS,
4081
- now: this.deps.now
4278
+ now: this.deps.now,
4279
+ onText: (t) => stream.set(t)
4082
4280
  }) : null;
4281
+ stream.close();
4083
4282
  this.finish(r, id, { outcome: "delivered", mode, ...answer ? { reply: answer } : {} }, sent);
4084
4283
  })().catch((e) => this.finish(r, id, { outcome: "delivered", mode, detail: String(e?.message || e) }));
4085
4284
  }
4285
+ /** The answer so far, sent as a `progress` ack: whole, with a rising seq, at most
4286
+ * one in flight. Fire-and-forget — the final ack carries everything. */
4287
+ answerStream(r, id) {
4288
+ return new AnswerStream(async (text, seq) => {
4289
+ await this.post(r, "/ack", {
4290
+ acks: [
4291
+ {
4292
+ id,
4293
+ outcome: "progress",
4294
+ at: new Date(this.deps.now()).toISOString(),
4295
+ seq,
4296
+ reply: redactSecrets(text).slice(0, MAX_REPLY)
4297
+ }
4298
+ ]
4299
+ });
4300
+ });
4301
+ }
4302
+ /** How a headless run prints: streamed where this Claude Code can, else one JSON at the end. */
4303
+ outputArgs(caps) {
4304
+ return caps.stream ? ["--output-format", "stream-json", "--verbose", "--include-partial-messages"] : ["--output-format", "json"];
4305
+ }
4086
4306
  limits() {
4087
4307
  const l = this.cfg.limits ?? {};
4088
4308
  const n = (v, d) => typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : d;
@@ -4139,7 +4359,7 @@ ${IN_PLACE_ANSWER}`, id: p.msgId });
4139
4359
  if (this.deps.sdk) {
4140
4360
  const sdk = this.deps.sdk;
4141
4361
  const out3 = await this.slot(async () => {
4142
- const s = sdk.start({ session: ref.id, cwd, mode: this.cfg.mode });
4362
+ const s = sdk.start({ session: ref.id, cwd, mode: this.cfg.mode, allowedTools: this.allowedFor(r) });
4143
4363
  this.sdkSessions.set(ref.id, s);
4144
4364
  return s.send(frame(cmd2.text, r.url));
4145
4365
  });
@@ -4155,13 +4375,28 @@ ${IN_PLACE_ANSWER}`, id: p.msgId });
4155
4375
  return this.finish(r, id, { outcome: "failed", detail: "Claude Code isn't installed on this machine" });
4156
4376
  const mf = modeFlags2(this.cfg.mode, claude.caps);
4157
4377
  if ("error" in mf) return this.finish(r, id, { outcome: "refused", code: "mode", detail: mf.error });
4378
+ const stream = this.answerStream(r, id);
4158
4379
  const res = await this.slot(
4159
4380
  () => this.deps.run(
4160
4381
  claude.bin,
4161
- ["-p", "--output-format", "json", ...mf.args, "--session-id", ref.id, frame(cmd2.text, r.url)],
4162
- { cwd, env: claudeEnv2(void 0, this.deps.env), timeoutMs: JOB_TIMEOUT_MS2 }
4382
+ [
4383
+ "-p",
4384
+ ...this.outputArgs(claude.caps),
4385
+ ...mf.args,
4386
+ ...this.allowedFor(r).length ? [`--allowedTools=${this.allowedFor(r).join(",")}`] : [],
4387
+ "--session-id",
4388
+ ref.id,
4389
+ frame(cmd2.text, r.url)
4390
+ ],
4391
+ {
4392
+ cwd,
4393
+ env: claudeEnv2(void 0, this.deps.env),
4394
+ timeoutMs: JOB_TIMEOUT_MS2,
4395
+ ...claude.caps.stream ? { onLine: (l) => stream.feed(l) } : {}
4396
+ }
4163
4397
  )
4164
4398
  );
4399
+ stream.close();
4165
4400
  this.lastEnd.set(ref.id, this.deps.now());
4166
4401
  const out2 = this.outcome(res);
4167
4402
  if ("failed" in out2) return this.finish(r, id, { outcome: "failed", detail: out2.failed });
@@ -4222,10 +4457,11 @@ function ask(sock, req, timeoutMs) {
4222
4457
 
4223
4458
  // src/control/pair.ts
4224
4459
  import { execFileSync as execFileSync5 } from "node:child_process";
4460
+ import { randomBytes as randomBytes2 } from "node:crypto";
4225
4461
  import { existsSync as existsSync10, mkdirSync as mkdirSync9, unlinkSync as unlinkSync5, writeFileSync as writeFileSync7 } from "node:fs";
4226
4462
  import os4 from "node:os";
4227
4463
  import path15 from "node:path";
4228
- var VERSION2 = true ? "0.2.0" : "0.0.0";
4464
+ var VERSION2 = true ? "0.4.0" : "0.0.0";
4229
4465
  async function json(r) {
4230
4466
  try {
4231
4467
  return await r.json();
@@ -4240,23 +4476,34 @@ async function discover(url, f) {
4240
4476
  const wk = await r.json();
4241
4477
  if (!wk.capabilities?.includes("control") || !wk.endpoints?.control || !wk.control?.signing?.rp_id)
4242
4478
  throw new Error(`${base} doesn't serve control yet (its well-known file lists no control.signing)`);
4243
- const ep = wk.endpoints.control;
4244
- return { control: /^https?:\/\//.test(ep) ? ep : base + ep, rpId: wk.control.signing.rp_id };
4479
+ const abs = (ep) => /^https?:\/\//.test(ep) ? ep : base + ep;
4480
+ return {
4481
+ control: abs(wk.endpoints.control),
4482
+ rpId: wk.control.signing.rp_id,
4483
+ open: wk.control.open_pairing === true,
4484
+ events: wk.endpoints.events ? abs(wk.endpoints.events) : null
4485
+ };
4245
4486
  }
4246
4487
  async function pair(o) {
4247
4488
  const f = o.fetch ?? fetch;
4248
4489
  const sleep2 = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
4249
- const { control, rpId } = await discover(o.url, f);
4490
+ const { control, rpId, open } = await discover(o.url, f);
4491
+ if (!o.token && !open)
4492
+ throw new Error(
4493
+ `${o.url} needs a key to pair: add it as a sink first (\`sessionpipe sink add ${o.url} --token \u2026\`) or pass --token.`
4494
+ );
4495
+ const pollKey = o.token ? null : randomBytes2(32).toString("base64url");
4250
4496
  const cfg = readControl(o.env) ?? { name: o.name, folders: [], mode: o.mode, receivers: [] };
4251
4497
  cfg.name = o.name || cfg.name;
4252
4498
  cfg.mode = o.mode;
4253
4499
  cfg.folders = [.../* @__PURE__ */ new Set([...cfg.folders, ...o.folders.map((d) => path15.resolve(d))])];
4254
4500
  const existing = cfg.receivers.find((r) => r.control === control);
4255
- const auth = { authorization: `Bearer ${o.token}`, "content-type": "application/json" };
4501
+ const auth = { authorization: `Bearer ${o.token ?? pollKey}`, "content-type": "application/json" };
4256
4502
  const started = await f(`${control}/pair`, {
4257
4503
  method: "POST",
4258
- headers: auth,
4504
+ headers: o.token ? auth : { "content-type": "application/json" },
4259
4505
  body: JSON.stringify({
4506
+ ...pollKey ? { poll_key: pollKey } : {},
4260
4507
  ...existing ? { machine: existing.machine } : {},
4261
4508
  name: cfg.name,
4262
4509
  harnesses: ["claude-code"],
@@ -4274,9 +4521,16 @@ async function pair(o) {
4274
4521
  const machine = String(s.machine);
4275
4522
  const code = String(s.code);
4276
4523
  o.out("");
4277
- o.out(` Open this link and confirm with your passkey (Touch ID, Face ID, a security key):`);
4278
- o.out(` ${String(s.url)}`);
4279
- o.out(` The page shows these digits too: ${String(s.check)}`);
4524
+ const digits = String(s.check);
4525
+ const spaced = /^\d{6}$/.test(digits) ? `${digits.slice(0, 3)} ${digits.slice(3)}` : digits;
4526
+ if (pollKey) {
4527
+ o.out(` Open ${String(s.url)} and enter: ${spaced}`);
4528
+ o.out(" then approve with your passkey (Touch ID, Face ID, a security key).");
4529
+ } else {
4530
+ o.out(" Open this link and confirm with your passkey (Touch ID, Face ID, a security key):");
4531
+ o.out(` ${String(s.url)}`);
4532
+ o.out(` The page shows these digits too: ${digits}`);
4533
+ }
4280
4534
  o.out("");
4281
4535
  const until = Date.parse(String(s.expires_at)) || Date.now() + 10 * 6e4;
4282
4536
  for (; ; ) {
@@ -4307,6 +4561,9 @@ async function pair(o) {
4307
4561
  if (!existing) cfg.receivers.push(rec);
4308
4562
  writeControl(cfg, o.env);
4309
4563
  o.out(` \u2713 Paired with ${rec.url} as ${machine} \xB7 ${rec.keys.length} key(s) trusted on this machine`);
4564
+ if (typeof p.account === "string")
4565
+ o.out(` Approved by ${p.account}. If that isn't you, run \`sessionpipe control off\` now.`);
4566
+ if (typeof p.sink_token === "string" && o.addSink) o.addSink(p.sink_token);
4310
4567
  return cfg;
4311
4568
  }
4312
4569
  }
@@ -4440,7 +4697,8 @@ async function loadSdk(o) {
4440
4697
  start({
4441
4698
  session,
4442
4699
  cwd,
4443
- mode
4700
+ mode,
4701
+ allowedTools
4444
4702
  }) {
4445
4703
  const queue = [];
4446
4704
  let wake = null;
@@ -4459,6 +4717,7 @@ async function loadSdk(o) {
4459
4717
  sessionId: session,
4460
4718
  cwd,
4461
4719
  permissionMode: mode === "auto" ? "auto" : "dontAsk",
4720
+ ...allowedTools?.length ? { allowedTools } : {},
4462
4721
  ...o.claudeBin ? { pathToClaudeCodeExecutable: o.claudeBin } : {},
4463
4722
  env: { ...o.env ?? process.env, SESSIONPIPE_CONTROL_JOB: "1" }
4464
4723
  }
@@ -4534,8 +4793,34 @@ ${RE_ARM}`;
4534
4793
  // src/control/cli.ts
4535
4794
  var { detectCaps: detectCaps2, findClaude: findClaude2, runClaude: runClaude2 } = claude_code_control_exports;
4536
4795
  var CONTROL_HELP = ` sessionpipe control pair <receiver> [--folder DIR]\u2026 [--mode safe|auto] [--token T] [--name N] [--no-service]
4796
+ sessionpipe control mode auto|safe (how sessions run when you message them)
4537
4797
  sessionpipe control status | keys [remove <id>] | off [<receiver>] | run
4538
4798
  sessionpipe wait [--session <harness>:<id>] (a session runs this in the background)`;
4799
+ var MODE_TEXT = ` auto Claude Code's auto mode: the session reads, edits and runs commands, and Claude Code's
4800
+ own safety check stops what looks dangerous. Pick this to get work done from your phone.
4801
+ safe Only what each folder's Claude Code settings already allow: with no allow rules, a
4802
+ session can't even read a file. Pick this if this machine holds things no agent
4803
+ should touch unattended.`;
4804
+ async function askMode(out2) {
4805
+ if (!process.stdin.isTTY) {
4806
+ out2(" Mode: safe for now. Run `sessionpipe control mode auto` to let sessions you message work unattended.");
4807
+ return "safe";
4808
+ }
4809
+ out2("");
4810
+ out2(" When you message a session on this machine from your phone, how may it run?");
4811
+ out2(MODE_TEXT);
4812
+ const { createInterface } = await import("node:readline/promises");
4813
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
4814
+ try {
4815
+ for (; ; ) {
4816
+ const a = (await rl.question(" auto or safe? [auto] ")).trim().toLowerCase();
4817
+ if (a === "" || a === "a" || a === "auto") return "auto";
4818
+ if (a === "s" || a === "safe") return "safe";
4819
+ }
4820
+ } finally {
4821
+ rl.close();
4822
+ }
4823
+ }
4539
4824
  async function controlMain(argv, out2, distDir2) {
4540
4825
  const flag2 = (n) => {
4541
4826
  const i = argv.indexOf(n);
@@ -4545,24 +4830,37 @@ async function controlMain(argv, out2, distDir2) {
4545
4830
  const sub = argv[1];
4546
4831
  switch (sub) {
4547
4832
  case "pair": {
4548
- const url = argv[2];
4549
- if (!url || !/^https?:\/\//.test(url)) return out2(`usage:
4833
+ const raw = argv[2];
4834
+ if (!raw || raw.startsWith("-")) return out2(`usage:
4550
4835
  ${CONTROL_HELP}`);
4836
+ const url = (/^https?:\/\//.test(raw) ? raw : `https://${raw}`).replace(/\/$/, "");
4551
4837
  const cfg = readConfig();
4552
- const sink2 = cfg.sinks.find((s) => s.url.replace(/\/$/, "") === url.replace(/\/$/, ""));
4553
- const token = flag2("--token") ?? sink2?.token;
4554
- if (!token)
4838
+ const sink2 = cfg.sinks.find((s) => s.url.replace(/\/$/, "") === url);
4839
+ const token = flag2("--token") ?? sink2?.token ?? null;
4840
+ if (token !== null && !/^[\x21-\x7e]+$/.test(token))
4555
4841
  return out2(
4556
- ` No token for ${url}: add it as a sink first (\`sessionpipe sink add ${url} --token \u2026\`) or pass --token.`
4842
+ " That token has a character a key never has (a placeholder like ss_\u2026 pasted as is?). Leave --token out to pair with a link instead."
4557
4843
  );
4558
4844
  const folders = flags("--folder");
4559
4845
  const existing = readControl();
4560
4846
  if (!folders.length && !existing?.folders.length) folders.push(process.cwd());
4847
+ const mode = flag2("--mode") === "auto" || flag2("--mode") === "safe" ? flag2("--mode") : existing?.mode && existing.receivers.length ? existing.mode : await askMode(out2);
4561
4848
  await pair({
4562
4849
  url,
4563
4850
  token,
4851
+ // The key the receiver handed over at pairing becomes this machine's sink for it,
4852
+ // state only (tier 0), unless one is configured already.
4853
+ addSink: (t) => {
4854
+ const now = readConfig();
4855
+ if (now.sinks.some((x) => x.url.replace(/\/$/, "") === url)) return;
4856
+ now.sinks.push({ name: new URL(url).host, url, tier: 0, pii: false, control: false, token: t });
4857
+ writeConfig(now);
4858
+ out2(
4859
+ ` \u2713 Sink ${new URL(url).host} added (tier 0: session state, no conversation text). \`sessionpipe sink add ${url} --tier 2\` sends transcripts too.`
4860
+ );
4861
+ },
4564
4862
  folders,
4565
- mode: flag2("--mode") === "auto" ? "auto" : existing?.mode ?? "safe",
4863
+ mode,
4566
4864
  name: flag2("--name") ?? existing?.name ?? cfg.machine ?? os5.hostname(),
4567
4865
  out: out2
4568
4866
  });
@@ -4581,6 +4879,22 @@ ${CONTROL_HELP}`);
4581
4879
  else out2(` Daemon: ${installService({ node: process.execPath, cli })}`);
4582
4880
  return;
4583
4881
  }
4882
+ case "mode": {
4883
+ const want = argv[2];
4884
+ if (want !== "auto" && want !== "safe") {
4885
+ const c2 = readControl();
4886
+ return out2(` mode: ${c2?.mode ?? "not set up"}
4887
+ usage: sessionpipe control mode auto|safe
4888
+ ${MODE_TEXT}`);
4889
+ }
4890
+ const c = readControl();
4891
+ if (!c) return out2(" control isn't set up on this machine: run `sessionpipe control pair <receiver>` first");
4892
+ c.mode = want;
4893
+ writeControl(c);
4894
+ return out2(
4895
+ ` \u2713 Sessions you message now run in ${want} mode${want === "auto" ? " (Claude Code's auto mode decides what's safe)" : " (only what each folder's settings already allow)"}.`
4896
+ );
4897
+ }
4584
4898
  case "keys": {
4585
4899
  const c = readControl();
4586
4900
  if (argv[2] === "remove" && argv[3]) {
@@ -4779,7 +5093,7 @@ async function tellDaemon(state2, job, session, transcript) {
4779
5093
  300
4780
5094
  );
4781
5095
  }
4782
- var VERSION3 = true ? "0.2.0" : "0.0.0";
5096
+ var VERSION3 = true ? "0.4.0" : "0.0.0";
4783
5097
  function enqueueJob(state2, job) {
4784
5098
  const jobs = jobsDir(state2);
4785
5099
  mkdirSync10(jobs, { recursive: true, mode: 448 });