hatchee 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.
Files changed (2) hide show
  1. package/dist/cli.mjs +193 -23
  2. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -8018,7 +8018,7 @@ function flattenItem(item) {
8018
8018
  return { text: item.text, color: item.color };
8019
8019
  }
8020
8020
  }
8021
- var PROTOCOL_VERSION = 3, PROTOCOL_MIN = 1, AgentState, RuleAction, Rule, DiffStat, LineColor, TermLine, ToolDecision, TranscriptItem, SessionSnapshot, ServerHello, Encrypted, SessionUpsert, SessionRemoved, Line, Item, PermissionRequest, PermissionResolved, ErrorMsg, Pong, RulesUpdate, DirEntry, DirsUpdate, RecentSession, SessionsUpdate, DaemonToPhone, DaemonInner, ClientHello, PermissionReply, RulesSync, UserMessage, SpawnMode, SpawnEffort, Spawn, Stop, SetSession, Interrupt, ListDirs, ListSessions, Ping, PushRegister, PhoneToDaemon, PhoneInner, clip = (s, n) => s.length > n ? s.slice(0, n - 1) + "…" : s;
8021
+ var PROTOCOL_VERSION = 4, PROTOCOL_MIN = 1, AgentState, RuleAction, Rule, DiffStat, LineColor, TermLine, ToolDecision, TranscriptItem, SessionSnapshot, ServerHello, Encrypted, SessionUpsert, SessionRemoved, Line, Item, PermissionRequest, PermissionResolved, ErrorMsg, Pong, RulesUpdate, DirEntry, DirsUpdate, RecentSession, SessionsUpdate, DiffFile, DiffUpdate, DaemonToPhone, DaemonInner, ClientHello, PermissionReply, RulesSync, UserMessage, SpawnMode, SpawnEffort, Spawn, Stop, SetSession, Interrupt, ListDirs, ListSessions, GetDiff, Ping, PushRegister, PhoneToDaemon, PhoneInner, clip = (s, n) => s.length > n ? s.slice(0, n - 1) + "…" : s;
8022
8022
  var init_src = __esm(() => {
8023
8023
  init_zod();
8024
8024
  AgentState = exports_external.enum([
@@ -8163,6 +8163,15 @@ var init_src = __esm(() => {
8163
8163
  t: exports_external.literal("sessions"),
8164
8164
  sessions: exports_external.array(RecentSession)
8165
8165
  });
8166
+ DiffFile = exports_external.object({
8167
+ file: exports_external.string(),
8168
+ patch: exports_external.string()
8169
+ });
8170
+ DiffUpdate = exports_external.object({
8171
+ t: exports_external.literal("diff"),
8172
+ sessionId: exports_external.string(),
8173
+ files: exports_external.array(DiffFile)
8174
+ });
8166
8175
  DaemonToPhone = exports_external.discriminatedUnion("t", [
8167
8176
  ServerHello,
8168
8177
  SessionUpsert,
@@ -8176,6 +8185,7 @@ var init_src = __esm(() => {
8176
8185
  RulesUpdate,
8177
8186
  DirsUpdate,
8178
8187
  SessionsUpdate,
8188
+ DiffUpdate,
8179
8189
  Encrypted
8180
8190
  ]);
8181
8191
  DaemonInner = exports_external.discriminatedUnion("t", [
@@ -8189,7 +8199,8 @@ var init_src = __esm(() => {
8189
8199
  Pong,
8190
8200
  RulesUpdate,
8191
8201
  DirsUpdate,
8192
- SessionsUpdate
8202
+ SessionsUpdate,
8203
+ DiffUpdate
8193
8204
  ]);
8194
8205
  ClientHello = exports_external.object({
8195
8206
  t: exports_external.literal("hello"),
@@ -8202,7 +8213,8 @@ var init_src = __esm(() => {
8202
8213
  t: exports_external.literal("permission_reply"),
8203
8214
  requestId: exports_external.string(),
8204
8215
  allow: exports_external.boolean(),
8205
- always: exports_external.boolean().default(false)
8216
+ always: exports_external.boolean().default(false),
8217
+ answer: exports_external.string().nullable().default(null)
8206
8218
  });
8207
8219
  RulesSync = exports_external.object({
8208
8220
  t: exports_external.literal("rules_sync"),
@@ -8240,6 +8252,7 @@ var init_src = __esm(() => {
8240
8252
  });
8241
8253
  ListDirs = exports_external.object({ t: exports_external.literal("list_dirs") });
8242
8254
  ListSessions = exports_external.object({ t: exports_external.literal("list_sessions") });
8255
+ GetDiff = exports_external.object({ t: exports_external.literal("get_diff"), sessionId: exports_external.string() });
8243
8256
  Ping = exports_external.object({ t: exports_external.literal("ping") });
8244
8257
  PushRegister = exports_external.object({
8245
8258
  t: exports_external.literal("push_register"),
@@ -8258,6 +8271,7 @@ var init_src = __esm(() => {
8258
8271
  Interrupt,
8259
8272
  ListDirs,
8260
8273
  ListSessions,
8274
+ GetDiff,
8261
8275
  Ping,
8262
8276
  PushRegister,
8263
8277
  Encrypted
@@ -8272,6 +8286,7 @@ var init_src = __esm(() => {
8272
8286
  Interrupt,
8273
8287
  ListDirs,
8274
8288
  ListSessions,
8289
+ GetDiff,
8275
8290
  Ping,
8276
8291
  PushRegister
8277
8292
  ]);
@@ -11771,19 +11786,74 @@ class Registry {
11771
11786
  });
11772
11787
  return { branch, branches, diff };
11773
11788
  }
11789
+ gitDiff(cwd) {
11790
+ let raw = "";
11791
+ try {
11792
+ raw = execSync("git diff", { cwd, stdio: ["ignore", "pipe", "ignore"], timeout: 3000, maxBuffer: 4 * 1024 * 1024 }).toString();
11793
+ } catch {
11794
+ return [];
11795
+ }
11796
+ if (!raw.trim())
11797
+ return [];
11798
+ const files = [];
11799
+ for (const chunk of raw.split(/^diff --git /m).slice(1)) {
11800
+ if (files.length >= 12)
11801
+ break;
11802
+ const header = chunk.split(`
11803
+ `, 1)[0] ?? "";
11804
+ const file = header.match(/ b\/(.*)$/)?.[1] ?? header.split(" ").pop() ?? "?";
11805
+ const body = chunk.split(`
11806
+ `).filter((l) => l.startsWith("@@") || l.startsWith("+") || l.startsWith("-") || l.startsWith(" ")).join(`
11807
+ `);
11808
+ const lines = body.split(`
11809
+ `);
11810
+ const clipped = lines.length > 120 ? lines.slice(0, 120).join(`
11811
+ `) + `
11812
+ … (+${lines.length - 120} more lines)` : body;
11813
+ files.push({ file, patch: clipped.slice(0, 6000) });
11814
+ }
11815
+ return files;
11816
+ }
11817
+ prune(now = Date.now(), keepAlive = () => false) {
11818
+ const ENDED_TTL = 60 * 60000;
11819
+ const IDLE_TTL = 12 * 60 * 60000;
11820
+ const removed = [];
11821
+ for (const [id, s] of this.sessions) {
11822
+ if (keepAlive(id))
11823
+ continue;
11824
+ const ttl = ["done", "canceled", "error"].includes(s.state) ? ENDED_TTL : IDLE_TTL;
11825
+ if (now - s.updatedAt > ttl)
11826
+ removed.push(id);
11827
+ }
11828
+ for (const id of removed) {
11829
+ this.sessions.delete(id);
11830
+ this.items.delete(id);
11831
+ }
11832
+ return removed;
11833
+ }
11774
11834
  }
11775
11835
 
11776
11836
  // src/transcript.ts
11777
11837
  init_src();
11778
11838
  import { openSync, readSync, closeSync, fstatSync, readdirSync, statSync, existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
11779
11839
  import { homedir as homedir2 } from "node:os";
11780
- import { join as join2, basename } from "node:path";
11840
+ import { join as join2, basename, resolve, sep } from "node:path";
11781
11841
  var claudeDir = () => process.env.CLAUDE_CONFIG_DIR || join2(homedir2(), ".claude");
11842
+ function isSafeTranscriptPath(path) {
11843
+ if (!path.endsWith(".jsonl"))
11844
+ return false;
11845
+ const real = resolve(path);
11846
+ const root = resolve(claudeDir());
11847
+ return real === root || real.startsWith(root + sep);
11848
+ }
11782
11849
 
11783
11850
  class TranscriptReader {
11784
11851
  offsets = new Map;
11852
+ static MAX_READ = 256 * 1024;
11785
11853
  read(sessionId, path) {
11786
- if (typeof path !== "string" || !path)
11854
+ if (typeof path !== "string" || !path || !isSafeTranscriptPath(path))
11855
+ return [];
11856
+ if (basename(path) !== `${sessionId}.jsonl`)
11787
11857
  return [];
11788
11858
  let fd;
11789
11859
  try {
@@ -11798,6 +11868,8 @@ class TranscriptReader {
11798
11868
  off = 0;
11799
11869
  if (size <= off)
11800
11870
  return [];
11871
+ if (size - off > TranscriptReader.MAX_READ)
11872
+ off = size - TranscriptReader.MAX_READ;
11801
11873
  const buf = Buffer.alloc(size - off);
11802
11874
  readSync(fd, buf, 0, buf.length, off);
11803
11875
  const text = buf.toString("utf8");
@@ -12028,6 +12100,11 @@ class Daemon {
12028
12100
  if (p.authed)
12029
12101
  this.sendTo(p, msg);
12030
12102
  }
12103
+ broadcastV(minProto, msg) {
12104
+ for (const p of this.phones)
12105
+ if (p.authed && p.proto >= minProto)
12106
+ this.sendTo(p, msg);
12107
+ }
12031
12108
  emit(sessionId, item) {
12032
12109
  this.registry.appendItem(sessionId, item);
12033
12110
  for (const p of this.phones) {
@@ -12161,6 +12238,14 @@ class Daemon {
12161
12238
  this.spawnState(sessionId, "error", "couldn't start Claude Code");
12162
12239
  this.spawned.delete(sessionId);
12163
12240
  }
12241
+ pruneSessions(now = Date.now()) {
12242
+ const removed = this.registry.prune(now, (id) => this.spawned.has(id));
12243
+ for (const id of removed) {
12244
+ this.openTools.delete(id);
12245
+ this.broadcast({ t: "session_removed", sessionId: id });
12246
+ }
12247
+ return removed;
12248
+ }
12164
12249
  wakePhones(server, danger) {
12165
12250
  if (!this.relayPush)
12166
12251
  return;
@@ -12265,17 +12350,17 @@ class Daemon {
12265
12350
  return;
12266
12351
  this.pending.delete(msg.requestId);
12267
12352
  clearTimeout(req.timer);
12268
- if (msg.always && msg.allow && !req.danger) {
12353
+ if (msg.always && msg.allow && !req.danger && req.kind === "tool") {
12269
12354
  const pattern = req.command.split(/\s+/).slice(0, 2).join(" ");
12270
12355
  if (pattern && !this.cfg.rules.some((r) => r.pattern === pattern && r.scope === this.cfg.host)) {
12271
12356
  this.cfg.rules.unshift({ pattern, action: "allow", scope: this.cfg.host });
12272
12357
  this.persist();
12273
12358
  }
12274
12359
  }
12275
- req.resolve({ allow: msg.allow, always: msg.always, by: "phone" });
12360
+ req.resolve({ allow: msg.allow, always: msg.always, by: "phone", answer: msg.answer ?? null });
12276
12361
  this.broadcast({ t: "permission_resolved", requestId: msg.requestId, allowed: msg.allow, by: "phone" });
12277
12362
  const s = this.registry.sessions.get(req.sessionId);
12278
- if (s) {
12363
+ if (s && req.kind === "tool") {
12279
12364
  this.broadcast({
12280
12365
  t: "session_upsert",
12281
12366
  session: this.registry.upsert({ id: s.id, state: msg.allow ? "working" : "canceled", action: msg.allow ? "continuing…" : "denied from phone", command: "", plan: [] }, this.cfg.host)
@@ -12344,6 +12429,11 @@ class Daemon {
12344
12429
  case "list_sessions":
12345
12430
  this.sendTo(p, { t: "sessions", sessions: listRecentSessions() });
12346
12431
  break;
12432
+ case "get_diff": {
12433
+ const cwd = this.registry.sessions.get(msg.sessionId)?.cwd;
12434
+ this.sendTo(p, { t: "diff", sessionId: msg.sessionId, files: cwd ? this.registry.gitDiff(cwd) : [] });
12435
+ break;
12436
+ }
12347
12437
  case "stop": {
12348
12438
  const owned = this.spawned.get(msg.sessionId);
12349
12439
  if (owned) {
@@ -12442,14 +12532,18 @@ class Daemon {
12442
12532
  }
12443
12533
  }
12444
12534
  async handlePreToolUse(body, sessionId, cwd, isSpawned = false) {
12535
+ if (body.tool_name === "AskUserQuestion")
12536
+ return this.handleQuestion(body, sessionId, cwd);
12445
12537
  const command = toolSummary(body.tool_name, body.tool_input);
12446
12538
  const rawCommand = body.tool_name === "Bash" ? String(body.tool_input?.command ?? "") : command;
12447
12539
  const danger = isDangerous(rawCommand) || isDangerousTool(body.tool_name, body.tool_input);
12448
12540
  const isPlan = body.tool_name === "ExitPlanMode";
12449
- const toolUseId = isPlan ? null : isSpawned ? this.streamToolTarget(sessionId) : this.registerOpenTool(sessionId, body);
12541
+ const toolUseId = isPlan || isSpawned ? null : this.registerOpenTool(sessionId, body);
12450
12542
  const mark = (d) => {
12451
12543
  if (toolUseId) {
12452
12544
  this.emit(sessionId, { kind: "tool_decision", id: crypto.randomUUID(), toolUseId, decision: d });
12545
+ } else if (isSpawned && !isPlan) {
12546
+ this.markStreamDecision(sessionId, body, d);
12453
12547
  } else {
12454
12548
  const line = flattenItem({ kind: "tool_decision", id: "", toolUseId: "", decision: d });
12455
12549
  if (line)
@@ -12498,15 +12592,15 @@ class Daemon {
12498
12592
  expiresAt: Date.now() + HOOK_WAIT_MS
12499
12593
  });
12500
12594
  this.wakePhones(s.server, danger);
12501
- const result = await new Promise((resolve) => {
12595
+ const result = await new Promise((resolve2) => {
12502
12596
  const timer = setTimeout(() => {
12503
12597
  this.pending.delete(requestId);
12504
12598
  this.broadcast({ t: "permission_resolved", requestId, allowed: false, by: "timeout" });
12505
12599
  const back = this.registry.upsert({ id: sessionId, state: "working", action: "answer in the terminal (phone timed out)", command: "", plan: [] }, this.cfg.host);
12506
12600
  this.broadcast({ t: "session_upsert", session: back });
12507
- resolve({ allow: false, by: "timeout" });
12601
+ resolve2({ allow: false, by: "timeout" });
12508
12602
  }, HOOK_WAIT_MS);
12509
- this.pending.set(requestId, { requestId, sessionId, command, danger, timer, resolve });
12603
+ this.pending.set(requestId, { requestId, sessionId, command, danger, kind: "tool", timer, resolve: resolve2 });
12510
12604
  });
12511
12605
  if (result.by === "timeout") {
12512
12606
  mark("timeout");
@@ -12520,6 +12614,64 @@ class Daemon {
12520
12614
  note(sessionId, text, color) {
12521
12615
  this.emit(sessionId, { kind: "note", id: crypto.randomUUID(), text, color });
12522
12616
  }
12617
+ async handleQuestion(body, sessionId, cwd) {
12618
+ const qs = Array.isArray(body.tool_input?.questions) ? body.tool_input.questions : [];
12619
+ const q = qs[0] ?? {};
12620
+ const question = String(q.question ?? "Claude has a question").slice(0, 300);
12621
+ const options = Array.isArray(q.options) ? q.options.map((o) => String(o?.label ?? o ?? "")).filter(Boolean).slice(0, 4) : [];
12622
+ const connected = [...this.phones].some((p) => p.authed && p.proto >= 4);
12623
+ const wakeable = [...this.pushTokens.values()].some((t) => t.apnsToken);
12624
+ if (!connected && !wakeable)
12625
+ return decision("ask", "no phone connected");
12626
+ const requestId = crypto.randomUUID();
12627
+ this.emit(sessionId, { kind: "note", id: crypto.randomUUID(), text: `? ${question}`, color: "warn" });
12628
+ const s = this.registry.upsert({
12629
+ id: sessionId,
12630
+ cwd,
12631
+ state: "waiting",
12632
+ action: question.slice(0, 120),
12633
+ answers: options,
12634
+ command: ""
12635
+ }, this.cfg.host);
12636
+ this.broadcast({ t: "session_upsert", session: s });
12637
+ this.broadcastV(4, {
12638
+ t: "permission_request",
12639
+ requestId,
12640
+ sessionId,
12641
+ toolName: "AskUserQuestion",
12642
+ command: question,
12643
+ danger: false,
12644
+ expiresAt: Date.now() + HOOK_WAIT_MS
12645
+ });
12646
+ this.wakePhones(s.server, false);
12647
+ const result = await new Promise((resolve2) => {
12648
+ const timer = setTimeout(() => {
12649
+ this.pending.delete(requestId);
12650
+ this.broadcast({ t: "permission_resolved", requestId, allowed: false, by: "timeout" });
12651
+ const back2 = this.registry.upsert({ id: sessionId, state: "working", action: "answer in the terminal (phone timed out)", answers: [] }, this.cfg.host);
12652
+ this.broadcast({ t: "session_upsert", session: back2 });
12653
+ resolve2({ allow: false, by: "timeout" });
12654
+ }, HOOK_WAIT_MS);
12655
+ this.pending.set(requestId, {
12656
+ requestId,
12657
+ sessionId,
12658
+ command: question,
12659
+ danger: false,
12660
+ kind: "question",
12661
+ timer,
12662
+ resolve: resolve2
12663
+ });
12664
+ });
12665
+ if (result.by === "timeout")
12666
+ return decision("ask", "phone did not answer");
12667
+ const answer = (result.answer ?? "").trim();
12668
+ const back = this.registry.upsert({ id: sessionId, state: "working", action: "continuing…", answers: [] }, this.cfg.host);
12669
+ this.broadcast({ t: "session_upsert", session: back });
12670
+ if (!answer)
12671
+ return decision("ask", "answer it in the terminal");
12672
+ this.emit(sessionId, { kind: "user", id: crypto.randomUUID(), text: answer });
12673
+ return decision("deny", `The user answered the question from their phone: "${answer}". Proceed with that answer — do not ask again.`);
12674
+ }
12523
12675
  registerOpenTool(sessionId, body) {
12524
12676
  const id = typeof body.tool_use_id === "string" && body.tool_use_id ? body.tool_use_id : crypto.randomUUID();
12525
12677
  const tool = String(body.tool_name ?? "tool");
@@ -12537,20 +12689,34 @@ class Daemon {
12537
12689
  this.openTools.set(sessionId, open2);
12538
12690
  return id;
12539
12691
  }
12540
- streamToolTarget(sessionId) {
12692
+ streamToolTarget(sessionId, tool, detail) {
12541
12693
  const items = this.registry.items.get(sessionId) ?? [];
12542
- const settled = new Set;
12543
- for (const i of items) {
12544
- if (i.kind === "tool_result" || i.kind === "tool_decision")
12545
- settled.add(i.toolUseId);
12546
- }
12694
+ const decided = new Set;
12695
+ for (const i of items)
12696
+ if (i.kind === "tool_decision")
12697
+ decided.add(i.toolUseId);
12547
12698
  for (let k = items.length - 1;k >= 0; k--) {
12548
12699
  const i = items[k];
12549
- if (i.kind === "tool_use" && !settled.has(i.id))
12700
+ if (i.kind === "tool_use" && !decided.has(i.id) && i.tool === tool && i.input === detail)
12550
12701
  return i.id;
12551
12702
  }
12552
12703
  return null;
12553
12704
  }
12705
+ async markStreamDecision(sessionId, body, d) {
12706
+ const tool = String(body.tool_name ?? "");
12707
+ const detail = toolDetail(tool, body.tool_input);
12708
+ for (let attempt = 0;attempt < 6; attempt++) {
12709
+ const target = this.streamToolTarget(sessionId, tool, detail);
12710
+ if (target) {
12711
+ this.emit(sessionId, { kind: "tool_decision", id: crypto.randomUUID(), toolUseId: target, decision: d });
12712
+ return;
12713
+ }
12714
+ await new Promise((r) => setTimeout(r, 150));
12715
+ }
12716
+ const line = flattenItem({ kind: "tool_decision", id: "", toolUseId: "", decision: d });
12717
+ if (line)
12718
+ this.note(sessionId, line.text, line.color);
12719
+ }
12554
12720
  closeOpenTool(sessionId, toolUseId) {
12555
12721
  const open2 = this.openTools.get(sessionId);
12556
12722
  if (!open2)
@@ -12578,6 +12744,8 @@ class Daemon {
12578
12744
  }
12579
12745
  listen(onListenError) {
12580
12746
  const self = this;
12747
+ const sweeper = setInterval(() => self.pruneSessions(), 600000);
12748
+ sweeper.unref?.();
12581
12749
  const wss = new import_websocket_server.default({ port: this.cfg.wsPort });
12582
12750
  if (onListenError)
12583
12751
  wss.on("error", onListenError);
@@ -12690,8 +12858,10 @@ function extractToolOutput(resp) {
12690
12858
  }
12691
12859
  function toolSummary(toolName, input) {
12692
12860
  switch (toolName) {
12693
- case "Bash":
12694
- return String(input?.command ?? "").slice(0, 500);
12861
+ case "Bash": {
12862
+ const cmd = String(input?.command ?? "");
12863
+ return cmd.length > 500 ? cmd.slice(0, 480) + " …[truncated]" : cmd;
12864
+ }
12695
12865
  case "Edit":
12696
12866
  case "MultiEdit":
12697
12867
  return `Edit ${input?.file_path ?? "?"}`;
@@ -12866,13 +13036,13 @@ function uninstallHooks() {
12866
13036
  } catch {}
12867
13037
  }
12868
13038
  function readStdin() {
12869
- return new Promise((resolve) => {
13039
+ return new Promise((resolve2) => {
12870
13040
  let data = "";
12871
13041
  let done = false;
12872
13042
  const finish = () => {
12873
13043
  if (!done) {
12874
13044
  done = true;
12875
- resolve(data);
13045
+ resolve2(data);
12876
13046
  }
12877
13047
  };
12878
13048
  process.stdin.setEncoding("utf8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hatchee",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Your coding agents, alive on your iPhone lock screen — Hatchee daemon. Approve Claude Code / Codex from your phone.",
5
5
  "type": "module",
6
6
  "bin": {