sessionpipe 0.3.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/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // sessionpipe 0.3.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.3.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 });
@@ -4226,7 +4461,7 @@ import { randomBytes as randomBytes2 } from "node:crypto";
4226
4461
  import { existsSync as existsSync10, mkdirSync as mkdirSync9, unlinkSync as unlinkSync5, writeFileSync as writeFileSync7 } from "node:fs";
4227
4462
  import os4 from "node:os";
4228
4463
  import path15 from "node:path";
4229
- var VERSION2 = true ? "0.3.0" : "0.0.0";
4464
+ var VERSION2 = true ? "0.4.0" : "0.0.0";
4230
4465
  async function json(r) {
4231
4466
  try {
4232
4467
  return await r.json();
@@ -4462,7 +4697,8 @@ async function loadSdk(o) {
4462
4697
  start({
4463
4698
  session,
4464
4699
  cwd,
4465
- mode
4700
+ mode,
4701
+ allowedTools
4466
4702
  }) {
4467
4703
  const queue = [];
4468
4704
  let wake = null;
@@ -4481,6 +4717,7 @@ async function loadSdk(o) {
4481
4717
  sessionId: session,
4482
4718
  cwd,
4483
4719
  permissionMode: mode === "auto" ? "auto" : "dontAsk",
4720
+ ...allowedTools?.length ? { allowedTools } : {},
4484
4721
  ...o.claudeBin ? { pathToClaudeCodeExecutable: o.claudeBin } : {},
4485
4722
  env: { ...o.env ?? process.env, SESSIONPIPE_CONTROL_JOB: "1" }
4486
4723
  }
@@ -4556,8 +4793,34 @@ ${RE_ARM}`;
4556
4793
  // src/control/cli.ts
4557
4794
  var { detectCaps: detectCaps2, findClaude: findClaude2, runClaude: runClaude2 } = claude_code_control_exports;
4558
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)
4559
4797
  sessionpipe control status | keys [remove <id>] | off [<receiver>] | run
4560
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
+ }
4561
4824
  async function controlMain(argv, out2, distDir2) {
4562
4825
  const flag2 = (n) => {
4563
4826
  const i = argv.indexOf(n);
@@ -4581,6 +4844,7 @@ ${CONTROL_HELP}`);
4581
4844
  const folders = flags("--folder");
4582
4845
  const existing = readControl();
4583
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);
4584
4848
  await pair({
4585
4849
  url,
4586
4850
  token,
@@ -4596,7 +4860,7 @@ ${CONTROL_HELP}`);
4596
4860
  );
4597
4861
  },
4598
4862
  folders,
4599
- mode: flag2("--mode") === "auto" ? "auto" : existing?.mode ?? "safe",
4863
+ mode,
4600
4864
  name: flag2("--name") ?? existing?.name ?? cfg.machine ?? os5.hostname(),
4601
4865
  out: out2
4602
4866
  });
@@ -4615,6 +4879,22 @@ ${CONTROL_HELP}`);
4615
4879
  else out2(` Daemon: ${installService({ node: process.execPath, cli })}`);
4616
4880
  return;
4617
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
+ }
4618
4898
  case "keys": {
4619
4899
  const c = readControl();
4620
4900
  if (argv[2] === "remove" && argv[3]) {
@@ -4813,7 +5093,7 @@ async function tellDaemon(state2, job, session, transcript) {
4813
5093
  300
4814
5094
  );
4815
5095
  }
4816
- var VERSION3 = true ? "0.3.0" : "0.0.0";
5096
+ var VERSION3 = true ? "0.4.0" : "0.0.0";
4817
5097
  function enqueueJob(state2, job) {
4818
5098
  const jobs = jobsDir(state2);
4819
5099
  mkdirSync10(jobs, { recursive: true, mode: 448 });