hatchee 0.2.0 → 0.3.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 +416 -58
  2. package/package.json +1 -1
package/dist/cli.mjs CHANGED
@@ -7925,8 +7925,13 @@ function toolTitle(name, input) {
7925
7925
  return `WebSearch(${clip(String(input?.query ?? "?"), 64)})`;
7926
7926
  case "Task":
7927
7927
  return `Task(${clip(String(input?.description ?? "agent"), 64)})`;
7928
- case "TodoWrite":
7929
- return "Update Todos";
7928
+ case "TodoWrite": {
7929
+ const todos = Array.isArray(input?.todos) ? input.todos : [];
7930
+ const done = todos.filter((t) => t?.status === "completed").length;
7931
+ return todos.length ? `Update Todos (${done}/${todos.length} done)` : "Update Todos";
7932
+ }
7933
+ case "ExitPlanMode":
7934
+ return "Plan ready for review";
7930
7935
  case "NotebookEdit":
7931
7936
  return `Edit(${clip(String(input?.notebook_path ?? "?"), 64)})`;
7932
7937
  default:
@@ -7953,6 +7958,14 @@ function toolDetail(name, input) {
7953
7958
  return String(input?.query ?? "");
7954
7959
  case "Task":
7955
7960
  return String(input?.prompt ?? input?.description ?? "").slice(0, 600);
7961
+ case "TodoWrite": {
7962
+ const todos = Array.isArray(input?.todos) ? input.todos : [];
7963
+ const box = (s) => s === "completed" ? "☑" : s === "in_progress" ? "◐" : "☐";
7964
+ return todos.map((t) => `${box(String(t?.status))} ${String(t?.content ?? "")}`).join(`
7965
+ `).slice(0, 1200);
7966
+ }
7967
+ case "ExitPlanMode":
7968
+ return String(input?.plan ?? "").slice(0, 2000);
7956
7969
  default:
7957
7970
  try {
7958
7971
  const s = JSON.stringify(input);
@@ -8005,7 +8018,7 @@ function flattenItem(item) {
8005
8018
  return { text: item.text, color: item.color };
8006
8019
  }
8007
8020
  }
8008
- var PROTOCOL_VERSION = 2, 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, DaemonToPhone, DaemonInner, ClientHello, PermissionReply, RulesSync, UserMessage, SpawnMode, SpawnEffort, Spawn, Stop, ListDirs, Ping, PushRegister, PhoneToDaemon, PhoneInner, clip = (s, n) => s.length > n ? s.slice(0, n - 1) + "…" : s;
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;
8009
8022
  var init_src = __esm(() => {
8010
8023
  init_zod();
8011
8024
  AgentState = exports_external.enum([
@@ -8076,6 +8089,7 @@ var init_src = __esm(() => {
8076
8089
  plan: exports_external.array(exports_external.string()).default([]),
8077
8090
  diff: exports_external.array(DiffStat).default([]),
8078
8091
  spawned: exports_external.boolean().default(false),
8092
+ mode: exports_external.string().nullable().default(null),
8079
8093
  startedAt: exports_external.number(),
8080
8094
  updatedAt: exports_external.number()
8081
8095
  });
@@ -8138,6 +8152,17 @@ var init_src = __esm(() => {
8138
8152
  t: exports_external.literal("dirs"),
8139
8153
  dirs: exports_external.array(DirEntry)
8140
8154
  });
8155
+ RecentSession = exports_external.object({
8156
+ id: exports_external.string(),
8157
+ cwd: exports_external.string(),
8158
+ name: exports_external.string(),
8159
+ summary: exports_external.string(),
8160
+ updatedAt: exports_external.number()
8161
+ });
8162
+ SessionsUpdate = exports_external.object({
8163
+ t: exports_external.literal("sessions"),
8164
+ sessions: exports_external.array(RecentSession)
8165
+ });
8141
8166
  DaemonToPhone = exports_external.discriminatedUnion("t", [
8142
8167
  ServerHello,
8143
8168
  SessionUpsert,
@@ -8150,6 +8175,7 @@ var init_src = __esm(() => {
8150
8175
  Pong,
8151
8176
  RulesUpdate,
8152
8177
  DirsUpdate,
8178
+ SessionsUpdate,
8153
8179
  Encrypted
8154
8180
  ]);
8155
8181
  DaemonInner = exports_external.discriminatedUnion("t", [
@@ -8162,7 +8188,8 @@ var init_src = __esm(() => {
8162
8188
  ErrorMsg,
8163
8189
  Pong,
8164
8190
  RulesUpdate,
8165
- DirsUpdate
8191
+ DirsUpdate,
8192
+ SessionsUpdate
8166
8193
  ]);
8167
8194
  ClientHello = exports_external.object({
8168
8195
  t: exports_external.literal("hello"),
@@ -8194,13 +8221,25 @@ var init_src = __esm(() => {
8194
8221
  cwd: exports_external.string().nullable().default(null),
8195
8222
  mode: SpawnMode.default("edits"),
8196
8223
  model: exports_external.string().nullable().default(null),
8197
- effort: SpawnEffort.nullable().default(null)
8224
+ effort: SpawnEffort.nullable().default(null),
8225
+ resume: exports_external.string().nullable().default(null)
8198
8226
  });
8199
8227
  Stop = exports_external.object({
8200
8228
  t: exports_external.literal("stop"),
8201
8229
  sessionId: exports_external.string()
8202
8230
  });
8231
+ SetSession = exports_external.object({
8232
+ t: exports_external.literal("set_session"),
8233
+ sessionId: exports_external.string(),
8234
+ mode: SpawnMode.nullable().default(null),
8235
+ model: exports_external.string().nullable().default(null)
8236
+ });
8237
+ Interrupt = exports_external.object({
8238
+ t: exports_external.literal("interrupt"),
8239
+ sessionId: exports_external.string()
8240
+ });
8203
8241
  ListDirs = exports_external.object({ t: exports_external.literal("list_dirs") });
8242
+ ListSessions = exports_external.object({ t: exports_external.literal("list_sessions") });
8204
8243
  Ping = exports_external.object({ t: exports_external.literal("ping") });
8205
8244
  PushRegister = exports_external.object({
8206
8245
  t: exports_external.literal("push_register"),
@@ -8215,7 +8254,10 @@ var init_src = __esm(() => {
8215
8254
  UserMessage,
8216
8255
  Spawn,
8217
8256
  Stop,
8257
+ SetSession,
8258
+ Interrupt,
8218
8259
  ListDirs,
8260
+ ListSessions,
8219
8261
  Ping,
8220
8262
  PushRegister,
8221
8263
  Encrypted
@@ -8226,7 +8268,10 @@ var init_src = __esm(() => {
8226
8268
  UserMessage,
8227
8269
  Spawn,
8228
8270
  Stop,
8271
+ SetSession,
8272
+ Interrupt,
8229
8273
  ListDirs,
8274
+ ListSessions,
8230
8275
  Ping,
8231
8276
  PushRegister
8232
8277
  ]);
@@ -8251,6 +8296,7 @@ class SpawnedSession {
8251
8296
  mode = "edits";
8252
8297
  claudeSessionId = null;
8253
8298
  stopped = false;
8299
+ busy = false;
8254
8300
  constructor(daemon, sessionId, cwd, model = null, effort = null, claudeBin = process.env.DROVER_CLAUDE_BIN || "claude") {
8255
8301
  this.daemon = daemon;
8256
8302
  this.sessionId = sessionId;
@@ -8263,6 +8309,29 @@ class SpawnedSession {
8263
8309
  this.mode = mode;
8264
8310
  this.spawnProc(firstTask);
8265
8311
  }
8312
+ resumeFrom(claudeSessionId) {
8313
+ this.claudeSessionId = claudeSessionId;
8314
+ }
8315
+ reconfigure(mode, model) {
8316
+ if (mode)
8317
+ this.mode = mode;
8318
+ if (model)
8319
+ this.model = model;
8320
+ if (this.proc && !this.busy) {
8321
+ try {
8322
+ this.proc.stdin.end();
8323
+ } catch {}
8324
+ }
8325
+ }
8326
+ interrupt() {
8327
+ if (!this.proc?.stdin.writable)
8328
+ return;
8329
+ const msg = { type: "control_request", request_id: crypto.randomUUID(), request: { subtype: "interrupt" } };
8330
+ try {
8331
+ this.proc.stdin.write(JSON.stringify(msg) + `
8332
+ `);
8333
+ } catch {}
8334
+ }
8266
8335
  spawnProc(firstMessage) {
8267
8336
  const args = [
8268
8337
  "--print",
@@ -8297,6 +8366,7 @@ class SpawnedSession {
8297
8366
  this.send(firstMessage);
8298
8367
  }
8299
8368
  send(text) {
8369
+ this.busy = true;
8300
8370
  if (this.proc?.stdin.writable) {
8301
8371
  const msg = { type: "user", message: { role: "user", content: text } };
8302
8372
  this.proc.stdin.write(JSON.stringify(msg) + `
@@ -8307,6 +8377,7 @@ class SpawnedSession {
8307
8377
  }
8308
8378
  onProcExit(_code) {
8309
8379
  this.proc = null;
8380
+ this.busy = false;
8310
8381
  if (this.stopped)
8311
8382
  return;
8312
8383
  this.daemon.spawnState(this.sessionId, "waiting", "ready — send your next message");
@@ -8331,8 +8402,10 @@ class SpawnedSession {
8331
8402
  switch (m.type) {
8332
8403
  case "system":
8333
8404
  if (m.subtype === "init") {
8334
- if (typeof m.session_id === "string")
8405
+ if (typeof m.session_id === "string") {
8335
8406
  this.claudeSessionId = m.session_id;
8407
+ this.daemon.adoptClaudeSession(this.sessionId, m.session_id);
8408
+ }
8336
8409
  this.daemon.spawnState(this.sessionId, "working", "session ready");
8337
8410
  }
8338
8411
  break;
@@ -8377,13 +8450,23 @@ class SpawnedSession {
8377
8450
  break;
8378
8451
  }
8379
8452
  case "result": {
8453
+ this.busy = false;
8380
8454
  const ok = m.is_error !== true;
8455
+ const u = m.usage;
8456
+ if (u && (u.input_tokens || u.output_tokens)) {
8457
+ const secs = m.duration_ms ? `${(m.duration_ms / 1000).toFixed(1)}s · ` : "";
8458
+ this.daemon.spawnLine(this.sessionId, `⏱ ${secs}${fmtTokens(u.input_tokens)} in / ${fmtTokens(u.output_tokens)} out`, "muted");
8459
+ }
8381
8460
  this.daemon.spawnState(this.sessionId, ok ? "waiting" : "error", ok ? "waiting for your next message" : String(m.result ?? "error"));
8382
8461
  break;
8383
8462
  }
8384
8463
  }
8385
8464
  }
8386
8465
  }
8466
+ function fmtTokens(n) {
8467
+ const v = Number(n) || 0;
8468
+ return v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v);
8469
+ }
8387
8470
  function contentText(content) {
8388
8471
  if (typeof content === "string")
8389
8472
  return content;
@@ -11612,9 +11695,9 @@ function lanIPv4() {
11612
11695
 
11613
11696
  // src/server.ts
11614
11697
  import { createServer } from "node:http";
11615
- import { readdirSync, statSync, existsSync as existsSync2 } from "node:fs";
11616
- import { homedir as homedir2 } from "node:os";
11617
- import { join as join2, basename } from "node:path";
11698
+ import { readdirSync as readdirSync2, statSync as statSync2, existsSync as existsSync3 } from "node:fs";
11699
+ import { homedir as homedir3 } from "node:os";
11700
+ import { join as join3, basename as basename2 } from "node:path";
11618
11701
 
11619
11702
  // ../../node_modules/ws/wrapper.mjs
11620
11703
  var import_stream = __toESM(require_stream(), 1);
@@ -11690,6 +11773,201 @@ class Registry {
11690
11773
  }
11691
11774
  }
11692
11775
 
11776
+ // src/transcript.ts
11777
+ init_src();
11778
+ import { openSync, readSync, closeSync, fstatSync, readdirSync, statSync, existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
11779
+ import { homedir as homedir2 } from "node:os";
11780
+ import { join as join2, basename } from "node:path";
11781
+ var claudeDir = () => process.env.CLAUDE_CONFIG_DIR || join2(homedir2(), ".claude");
11782
+
11783
+ class TranscriptReader {
11784
+ offsets = new Map;
11785
+ read(sessionId, path) {
11786
+ if (typeof path !== "string" || !path)
11787
+ return [];
11788
+ let fd;
11789
+ try {
11790
+ fd = openSync(path, "r");
11791
+ } catch {
11792
+ return [];
11793
+ }
11794
+ try {
11795
+ const size = fstatSync(fd).size;
11796
+ let off = this.offsets.get(sessionId) ?? 0;
11797
+ if (off > size)
11798
+ off = 0;
11799
+ if (size <= off)
11800
+ return [];
11801
+ const buf = Buffer.alloc(size - off);
11802
+ readSync(fd, buf, 0, buf.length, off);
11803
+ const text = buf.toString("utf8");
11804
+ const lastNL = text.lastIndexOf(`
11805
+ `);
11806
+ if (lastNL < 0)
11807
+ return [];
11808
+ this.offsets.set(sessionId, off + Buffer.byteLength(text.slice(0, lastNL + 1)));
11809
+ const items = [];
11810
+ for (const line of text.slice(0, lastNL).split(`
11811
+ `)) {
11812
+ parseEntry(line, items, { proseOnly: true });
11813
+ }
11814
+ return items;
11815
+ } catch {
11816
+ return [];
11817
+ } finally {
11818
+ try {
11819
+ closeSync(fd);
11820
+ } catch {}
11821
+ }
11822
+ }
11823
+ }
11824
+ function parseEntry(line, out, opts) {
11825
+ if (!line.trim())
11826
+ return;
11827
+ let e;
11828
+ try {
11829
+ e = JSON.parse(line);
11830
+ } catch {
11831
+ return;
11832
+ }
11833
+ if (e.isSidechain)
11834
+ return;
11835
+ const uuid = String(e.uuid ?? "");
11836
+ if (!uuid)
11837
+ return;
11838
+ const content = e.message?.content;
11839
+ if (e.type === "user" && !opts.proseOnly) {
11840
+ const text = typeof content === "string" ? content : Array.isArray(content) ? content.filter((b) => b?.type === "text").map((b) => String(b.text ?? "")).join(`
11841
+ `) : "";
11842
+ if (text.trim() && !text.startsWith("<")) {
11843
+ out.push({ kind: "user", id: `${uuid}:u`, text: clipOutput(text.trim(), 10, 500) });
11844
+ }
11845
+ return;
11846
+ }
11847
+ if (e.type !== "assistant" || !Array.isArray(content))
11848
+ return;
11849
+ content.forEach((b, idx) => {
11850
+ if (b?.type === "text" && String(b.text ?? "").trim()) {
11851
+ out.push({ kind: "text", id: `${uuid}:${idx}`, text: clipOutput(String(b.text).trim(), 60, 4000) });
11852
+ } else if (b?.type === "thinking" && String(b.thinking ?? "").trim()) {
11853
+ out.push({ kind: "thinking", id: `${uuid}:${idx}`, text: clipOutput(String(b.thinking).trim(), 40, 2000) });
11854
+ }
11855
+ });
11856
+ }
11857
+ function listRecentSessions(limit = 20) {
11858
+ const root = join2(claudeDir(), "projects");
11859
+ let dirs = [];
11860
+ try {
11861
+ dirs = readdirSync(root);
11862
+ } catch {
11863
+ return [];
11864
+ }
11865
+ const found = [];
11866
+ for (const d of dirs) {
11867
+ const dir = join2(root, d);
11868
+ let files = [];
11869
+ try {
11870
+ files = readdirSync(dir);
11871
+ } catch {
11872
+ continue;
11873
+ }
11874
+ for (const f of files) {
11875
+ if (!f.endsWith(".jsonl"))
11876
+ continue;
11877
+ const p = join2(dir, f);
11878
+ let mtime = 0, size = 0;
11879
+ try {
11880
+ const st = statSync(p);
11881
+ mtime = st.mtimeMs;
11882
+ size = st.size;
11883
+ } catch {
11884
+ continue;
11885
+ }
11886
+ if (size < 200)
11887
+ continue;
11888
+ found.push({ id: f.slice(0, -6), cwd: "", name: "", summary: "", updatedAt: Math.round(mtime), mtime });
11889
+ found[found.length - 1].path = p;
11890
+ }
11891
+ }
11892
+ found.sort((a, b) => b.mtime - a.mtime);
11893
+ const top = found.slice(0, limit);
11894
+ for (const s of top) {
11895
+ const meta = sessionMeta(s.path);
11896
+ s.cwd = meta.cwd;
11897
+ s.name = meta.cwd ? basename(meta.cwd) : "";
11898
+ s.summary = meta.summary;
11899
+ }
11900
+ return top.filter((s) => s.summary).map(({ id, cwd, name, summary, updatedAt }) => ({ id, cwd, name, summary, updatedAt }));
11901
+ }
11902
+ function sessionMeta(path) {
11903
+ let head = "";
11904
+ try {
11905
+ const fd = openSync(path, "r");
11906
+ try {
11907
+ const buf = Buffer.alloc(32 * 1024);
11908
+ const n = readSync(fd, buf, 0, buf.length, 0);
11909
+ head = buf.toString("utf8", 0, n);
11910
+ } finally {
11911
+ closeSync(fd);
11912
+ }
11913
+ } catch {
11914
+ return { cwd: "", summary: "" };
11915
+ }
11916
+ let cwd = "", summary = "";
11917
+ for (const line of head.split(`
11918
+ `)) {
11919
+ if (!line.trim())
11920
+ continue;
11921
+ let e;
11922
+ try {
11923
+ e = JSON.parse(line);
11924
+ } catch {
11925
+ continue;
11926
+ }
11927
+ if (!cwd && typeof e.cwd === "string")
11928
+ cwd = e.cwd;
11929
+ if (!summary && e.type === "summary" && typeof e.summary === "string")
11930
+ summary = e.summary;
11931
+ if (!summary && e.type === "user" && !e.isSidechain) {
11932
+ const c = e.message?.content;
11933
+ const text = typeof c === "string" ? c : Array.isArray(c) ? c.filter((b) => b?.type === "text").map((b) => String(b.text ?? "")).join(" ") : "";
11934
+ if (text.trim() && !text.startsWith("<"))
11935
+ summary = text.trim();
11936
+ }
11937
+ if (cwd && summary)
11938
+ break;
11939
+ }
11940
+ return { cwd, summary: summary.replace(/\s+/g, " ").slice(0, 120) };
11941
+ }
11942
+ function backfillItems(resumeId, maxItems = 20) {
11943
+ const root = join2(claudeDir(), "projects");
11944
+ let path = "";
11945
+ try {
11946
+ for (const d of readdirSync(root)) {
11947
+ const p = join2(root, d, `${resumeId}.jsonl`);
11948
+ if (existsSync2(p)) {
11949
+ path = p;
11950
+ break;
11951
+ }
11952
+ }
11953
+ } catch {
11954
+ return [];
11955
+ }
11956
+ if (!path)
11957
+ return [];
11958
+ let raw = "";
11959
+ try {
11960
+ raw = readFileSync2(path, "utf8");
11961
+ } catch {
11962
+ return [];
11963
+ }
11964
+ const items = [];
11965
+ for (const line of raw.split(`
11966
+ `))
11967
+ parseEntry(line, items, { proseOnly: false });
11968
+ return items.slice(-maxItems);
11969
+ }
11970
+
11693
11971
  // src/server.ts
11694
11972
  var VERSION = "0.1.0";
11695
11973
  var HOOK_WAIT_MS = 110000;
@@ -11704,6 +11982,8 @@ class Daemon {
11704
11982
  relayPush = null;
11705
11983
  spawned = new Map;
11706
11984
  openTools = new Map;
11985
+ aliases = new Map;
11986
+ transcripts = new TranscriptReader;
11707
11987
  defaultCwd = process.cwd();
11708
11988
  ephemeral;
11709
11989
  constructor(cfg, opts = {}) {
@@ -11797,7 +12077,7 @@ class Daemon {
11797
12077
  relayLeave(conn) {
11798
12078
  this.phones.delete(conn);
11799
12079
  }
11800
- async spawnSession(task, cwd, mode = "edits", model = null, effort = null) {
12080
+ async spawnSession(task, cwd, mode = "edits", model = null, effort = null, resume = null) {
11801
12081
  const { SpawnedSession: SpawnedSession2 } = await Promise.resolve().then(() => (init_spawn(), exports_spawn));
11802
12082
  const id = `spawn-${crypto.randomUUID()}`;
11803
12083
  const dir = cwd || this.defaultCwd;
@@ -11807,44 +12087,56 @@ class Daemon {
11807
12087
  agentName: "Claude Code",
11808
12088
  state: "working",
11809
12089
  spawned: true,
11810
- action: task ? task.slice(0, 60) : "starting…",
12090
+ mode,
12091
+ action: task ? task.slice(0, 60) : resume ? "continuing previous session…" : "starting…",
11811
12092
  ...this.registry.gitInfo(dir)
11812
12093
  }, this.cfg.host);
11813
12094
  this.broadcast({ t: "session_upsert", session: s });
12095
+ if (resume) {
12096
+ for (const item of backfillItems(resume))
12097
+ this.emit(id, item);
12098
+ this.aliases.set(resume, id);
12099
+ }
11814
12100
  if (task)
11815
12101
  this.emit(id, { kind: "user", id: crypto.randomUUID(), text: task });
11816
12102
  const session = new SpawnedSession2(this, id, dir, model, effort);
12103
+ if (resume)
12104
+ session.resumeFrom(resume);
11817
12105
  this.spawned.set(id, session);
11818
12106
  session.start(task, mode);
11819
12107
  }
12108
+ adoptClaudeSession(spawnId, claudeId) {
12109
+ if (claudeId && claudeId !== spawnId)
12110
+ this.aliases.set(claudeId, spawnId);
12111
+ }
11820
12112
  listProjectDirs() {
11821
- const home = homedir2();
11822
- const roots = [home, ...["Projects", "code", "dev", "src", "repos", "Developer", "work"].map((d) => join2(home, d))];
12113
+ const home = homedir3();
12114
+ const roots = [home, ...["Projects", "code", "dev", "src", "repos", "Developer", "work"].map((d) => join3(home, d))];
11823
12115
  const out = new Map;
11824
12116
  const add2 = (p) => {
11825
12117
  if (out.has(p))
11826
12118
  return;
11827
- out.set(p, { path: p, name: basename(p) || p, git: existsSync2(join2(p, ".git")) });
12119
+ out.set(p, { path: p, name: basename2(p) || p, git: existsSync3(join3(p, ".git")) });
11828
12120
  };
11829
12121
  add2(this.defaultCwd);
11830
12122
  for (const root of roots) {
11831
12123
  let entries = [];
11832
12124
  try {
11833
- entries = readdirSync(root);
12125
+ entries = readdirSync2(root);
11834
12126
  } catch {
11835
12127
  continue;
11836
12128
  }
11837
12129
  for (const e of entries) {
11838
12130
  if (e.startsWith(".") || out.size > 80)
11839
12131
  continue;
11840
- const p = join2(root, e);
12132
+ const p = join3(root, e);
11841
12133
  try {
11842
- if (!statSync(p).isDirectory())
12134
+ if (!statSync2(p).isDirectory())
11843
12135
  continue;
11844
12136
  } catch {
11845
12137
  continue;
11846
12138
  }
11847
- if (root !== home || existsSync2(join2(p, ".git")))
12139
+ if (root !== home || existsSync3(join3(p, ".git")))
11848
12140
  add2(p);
11849
12141
  }
11850
12142
  }
@@ -11986,7 +12278,7 @@ class Daemon {
11986
12278
  if (s) {
11987
12279
  this.broadcast({
11988
12280
  t: "session_upsert",
11989
- session: this.registry.upsert({ id: s.id, state: msg.allow ? "working" : "canceled", action: msg.allow ? "continuing…" : "denied from phone", command: "" }, this.cfg.host)
12281
+ session: this.registry.upsert({ id: s.id, state: msg.allow ? "working" : "canceled", action: msg.allow ? "continuing…" : "denied from phone", command: "", plan: [] }, this.cfg.host)
11990
12282
  });
11991
12283
  }
11992
12284
  break;
@@ -12025,12 +12317,33 @@ class Daemon {
12025
12317
  break;
12026
12318
  }
12027
12319
  case "spawn": {
12028
- this.spawnSession(msg.task ?? "", msg.cwd ?? null, msg.mode ?? "edits", msg.model ?? null, msg.effort ?? null);
12320
+ this.spawnSession(msg.task ?? "", msg.cwd ?? null, msg.mode ?? "edits", msg.model ?? null, msg.effort ?? null, msg.resume ?? null);
12321
+ break;
12322
+ }
12323
+ case "set_session": {
12324
+ const owned = this.spawned.get(msg.sessionId);
12325
+ if (!owned)
12326
+ break;
12327
+ owned.reconfigure(msg.mode ?? null, msg.model ?? null);
12328
+ if (msg.mode) {
12329
+ const s = this.registry.upsert({ id: msg.sessionId, mode: msg.mode }, this.cfg.host);
12330
+ this.broadcast({ t: "session_upsert", session: s });
12331
+ }
12332
+ const what = [msg.mode && `mode → ${msg.mode}`, msg.model && `model → ${msg.model}`].filter(Boolean).join(" · ");
12333
+ if (what)
12334
+ this.note(msg.sessionId, `⏵ ${what} (applies from the next turn)`, "muted");
12335
+ break;
12336
+ }
12337
+ case "interrupt": {
12338
+ this.spawned.get(msg.sessionId)?.interrupt();
12029
12339
  break;
12030
12340
  }
12031
12341
  case "list_dirs":
12032
12342
  this.sendTo(p, { t: "dirs", dirs: this.listProjectDirs() });
12033
12343
  break;
12344
+ case "list_sessions":
12345
+ this.sendTo(p, { t: "sessions", sessions: listRecentSessions() });
12346
+ break;
12034
12347
  case "stop": {
12035
12348
  const owned = this.spawned.get(msg.sessionId);
12036
12349
  if (owned) {
@@ -12048,10 +12361,21 @@ class Daemon {
12048
12361
  }
12049
12362
  async handleHookEvent(body) {
12050
12363
  const event = body.hook_event_name ?? "";
12051
- const sessionId = body.session_id ?? "unknown";
12364
+ const rawId = body.session_id ?? "unknown";
12365
+ const sessionId = this.aliases.get(rawId) ?? rawId;
12366
+ const isSpawned = sessionId !== rawId || this.spawned.has(sessionId);
12052
12367
  const cwd = body.cwd ?? "";
12368
+ if (!isSpawned) {
12369
+ for (const item of this.transcripts.read(sessionId, body.transcript_path)) {
12370
+ const seen = this.registry.items.get(sessionId)?.some((i) => i.id === item.id);
12371
+ if (!seen)
12372
+ this.emit(sessionId, item);
12373
+ }
12374
+ }
12053
12375
  switch (event) {
12054
12376
  case "SessionStart": {
12377
+ if (isSpawned)
12378
+ return { body: { ok: true } };
12055
12379
  const git = this.registry.gitInfo(cwd);
12056
12380
  const s = this.registry.upsert({
12057
12381
  id: sessionId,
@@ -12066,6 +12390,8 @@ class Daemon {
12066
12390
  return { body: { ok: true } };
12067
12391
  }
12068
12392
  case "UserPromptSubmit": {
12393
+ if (isSpawned)
12394
+ return { body: { ok: true } };
12069
12395
  const prompt = String(body.prompt ?? "").slice(0, 500);
12070
12396
  this.emit(sessionId, { kind: "user", id: crypto.randomUUID(), text: prompt });
12071
12397
  const s = this.registry.upsert({ id: sessionId, cwd, state: "working", action: prompt.slice(0, 80) }, this.cfg.host);
@@ -12073,8 +12399,10 @@ class Daemon {
12073
12399
  return { body: { ok: true } };
12074
12400
  }
12075
12401
  case "PreToolUse":
12076
- return this.handlePreToolUse(body, sessionId, cwd);
12402
+ return this.handlePreToolUse(body, sessionId, cwd, isSpawned);
12077
12403
  case "PostToolUse": {
12404
+ if (isSpawned)
12405
+ return { body: { ok: true } };
12078
12406
  const toolUseId = this.takeOpenTool(sessionId, body);
12079
12407
  const out = extractToolOutput(body.tool_response);
12080
12408
  this.emit(sessionId, {
@@ -12103,6 +12431,8 @@ class Daemon {
12103
12431
  return { body: { ok: true } };
12104
12432
  }
12105
12433
  case "Stop": {
12434
+ if (isSpawned)
12435
+ return { body: { ok: true } };
12106
12436
  const s = this.registry.upsert({ id: sessionId, cwd, state: "done", action: "finished", command: "" }, this.cfg.host);
12107
12437
  this.broadcast({ t: "session_upsert", session: s });
12108
12438
  return { body: { ok: true } };
@@ -12111,20 +12441,30 @@ class Daemon {
12111
12441
  return { body: { ok: true } };
12112
12442
  }
12113
12443
  }
12114
- async handlePreToolUse(body, sessionId, cwd) {
12444
+ async handlePreToolUse(body, sessionId, cwd, isSpawned = false) {
12115
12445
  const command = toolSummary(body.tool_name, body.tool_input);
12116
12446
  const rawCommand = body.tool_name === "Bash" ? String(body.tool_input?.command ?? "") : command;
12117
12447
  const danger = isDangerous(rawCommand) || isDangerousTool(body.tool_name, body.tool_input);
12118
- const toolUseId = this.registerOpenTool(sessionId, body);
12119
- const mark = (d) => this.emit(sessionId, { kind: "tool_decision", id: crypto.randomUUID(), toolUseId, decision: d });
12120
- const ruled = verdict(this.cfg.rules, rawCommand, this.cfg.host);
12448
+ const isPlan = body.tool_name === "ExitPlanMode";
12449
+ const toolUseId = isPlan ? null : isSpawned ? this.streamToolTarget(sessionId) : this.registerOpenTool(sessionId, body);
12450
+ const mark = (d) => {
12451
+ if (toolUseId) {
12452
+ this.emit(sessionId, { kind: "tool_decision", id: crypto.randomUUID(), toolUseId, decision: d });
12453
+ } else {
12454
+ const line = flattenItem({ kind: "tool_decision", id: "", toolUseId: "", decision: d });
12455
+ if (line)
12456
+ this.note(sessionId, line.text, line.color);
12457
+ }
12458
+ };
12459
+ const ruled = isPlan ? null : verdict(this.cfg.rules, rawCommand, this.cfg.host);
12121
12460
  if (!danger && ruled === "allow") {
12122
12461
  mark("auto");
12123
12462
  return decision("allow", "drover rule");
12124
12463
  }
12125
12464
  if (ruled === "deny") {
12126
12465
  mark("blocked");
12127
- this.closeOpenTool(sessionId, toolUseId);
12466
+ if (toolUseId)
12467
+ this.closeOpenTool(sessionId, toolUseId);
12128
12468
  const s2 = this.registry.upsert({ id: sessionId, cwd, state: "working", action: "blocked by your rules" }, this.cfg.host);
12129
12469
  this.broadcast({ t: "session_upsert", session: s2 });
12130
12470
  return decision("deny", "blocked by drover rule");
@@ -12135,13 +12475,17 @@ class Daemon {
12135
12475
  return decision("ask", "no phone connected");
12136
12476
  }
12137
12477
  const requestId = crypto.randomUUID();
12478
+ const plan = isPlan ? String(body.tool_input?.plan ?? "").split(`
12479
+ `).map((l) => l.trim()).filter(Boolean).slice(0, 30) : [];
12480
+ const displayCommand = isPlan ? "Review the plan" : command;
12138
12481
  const s = this.registry.upsert({
12139
12482
  id: sessionId,
12140
12483
  cwd,
12141
12484
  state: "approval",
12142
- action: danger ? "wants to run a DANGEROUS command" : `wants to run: ${command.slice(0, 60)}`,
12143
- command,
12144
- danger
12485
+ action: isPlan ? "plan ready — review before anything runs" : danger ? "wants to run a DANGEROUS command" : `wants to run: ${command.slice(0, 60)}`,
12486
+ command: displayCommand,
12487
+ danger,
12488
+ plan
12145
12489
  }, this.cfg.host);
12146
12490
  this.broadcast({ t: "session_upsert", session: s });
12147
12491
  this.broadcast({
@@ -12149,7 +12493,7 @@ class Daemon {
12149
12493
  requestId,
12150
12494
  sessionId,
12151
12495
  toolName: String(body.tool_name ?? ""),
12152
- command,
12496
+ command: displayCommand,
12153
12497
  danger,
12154
12498
  expiresAt: Date.now() + HOOK_WAIT_MS
12155
12499
  });
@@ -12158,7 +12502,7 @@ class Daemon {
12158
12502
  const timer = setTimeout(() => {
12159
12503
  this.pending.delete(requestId);
12160
12504
  this.broadcast({ t: "permission_resolved", requestId, allowed: false, by: "timeout" });
12161
- const back = this.registry.upsert({ id: sessionId, state: "working", action: "answer in the terminal (phone timed out)", command: "" }, this.cfg.host);
12505
+ const back = this.registry.upsert({ id: sessionId, state: "working", action: "answer in the terminal (phone timed out)", command: "", plan: [] }, this.cfg.host);
12162
12506
  this.broadcast({ t: "session_upsert", session: back });
12163
12507
  resolve({ allow: false, by: "timeout" });
12164
12508
  }, HOOK_WAIT_MS);
@@ -12193,6 +12537,20 @@ class Daemon {
12193
12537
  this.openTools.set(sessionId, open2);
12194
12538
  return id;
12195
12539
  }
12540
+ streamToolTarget(sessionId) {
12541
+ 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
+ }
12547
+ for (let k = items.length - 1;k >= 0; k--) {
12548
+ const i = items[k];
12549
+ if (i.kind === "tool_use" && !settled.has(i.id))
12550
+ return i.id;
12551
+ }
12552
+ return null;
12553
+ }
12196
12554
  closeOpenTool(sessionId, toolUseId) {
12197
12555
  const open2 = this.openTools.get(sessionId);
12198
12556
  if (!open2)
@@ -12449,18 +12807,18 @@ function b64url2(bytes) {
12449
12807
  }
12450
12808
 
12451
12809
  // src/hooks.ts
12452
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync3, mkdirSync as mkdirSync2 } from "node:fs";
12453
- import { homedir as homedir3 } from "node:os";
12454
- import { join as join3 } from "node:path";
12455
- var claudeDir = () => process.env.CLAUDE_CONFIG_DIR || join3(homedir3(), ".claude");
12456
- var settingsPath = () => join3(claudeDir(), "settings.json");
12810
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync2, existsSync as existsSync4, mkdirSync as mkdirSync2 } from "node:fs";
12811
+ import { homedir as homedir4 } from "node:os";
12812
+ import { join as join4 } from "node:path";
12813
+ var claudeDir2 = () => process.env.CLAUDE_CONFIG_DIR || join4(homedir4(), ".claude");
12814
+ var settingsPath = () => join4(claudeDir2(), "settings.json");
12457
12815
  var EVENTS = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Notification", "Stop"];
12458
12816
  function installHooks(droverBin) {
12459
- mkdirSync2(claudeDir(), { recursive: true });
12817
+ mkdirSync2(claudeDir2(), { recursive: true });
12460
12818
  let settings = {};
12461
- if (existsSync3(settingsPath())) {
12819
+ if (existsSync4(settingsPath())) {
12462
12820
  try {
12463
- settings = JSON.parse(readFileSync2(settingsPath(), "utf8"));
12821
+ settings = JSON.parse(readFileSync3(settingsPath(), "utf8"));
12464
12822
  } catch {
12465
12823
  settings = {};
12466
12824
  }
@@ -12495,10 +12853,10 @@ function isOurs(x) {
12495
12853
  return cmd.includes("hatchee") || cmd.includes("drover") || cmd.includes("daemon/src/cli.ts");
12496
12854
  }
12497
12855
  function uninstallHooks() {
12498
- if (!existsSync3(settingsPath()))
12856
+ if (!existsSync4(settingsPath()))
12499
12857
  return;
12500
12858
  try {
12501
- const settings = JSON.parse(readFileSync2(settingsPath(), "utf8"));
12859
+ const settings = JSON.parse(readFileSync3(settingsPath(), "utf8"));
12502
12860
  for (const event of Object.keys(settings.hooks ?? {})) {
12503
12861
  settings.hooks[event] = settings.hooks[event].filter((h) => !h?.hooks?.some(isOurs));
12504
12862
  if (settings.hooks[event].length === 0)
@@ -12560,27 +12918,27 @@ async function runHook(hookPort) {
12560
12918
  }
12561
12919
 
12562
12920
  // src/service.ts
12563
- import { homedir as homedir4 } from "node:os";
12564
- import { join as join4, dirname } from "node:path";
12565
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3, copyFileSync, chmodSync, existsSync as existsSync4, rmSync } from "node:fs";
12921
+ import { homedir as homedir5 } from "node:os";
12922
+ import { join as join5, dirname } from "node:path";
12923
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3, copyFileSync, chmodSync, existsSync as existsSync5, rmSync } from "node:fs";
12566
12924
  import { execFileSync } from "node:child_process";
12567
- var HOME = homedir4();
12568
- var HATCHEE_DIR = join4(HOME, ".hatchee");
12569
- var BIN_DIR = join4(HATCHEE_DIR, "bin");
12570
- var STABLE_BIN = join4(BIN_DIR, "hatchee.mjs");
12571
- var LOG = join4(HATCHEE_DIR, "daemon.log");
12925
+ var HOME = homedir5();
12926
+ var HATCHEE_DIR = join5(HOME, ".hatchee");
12927
+ var BIN_DIR = join5(HATCHEE_DIR, "bin");
12928
+ var STABLE_BIN = join5(BIN_DIR, "hatchee.mjs");
12929
+ var LOG = join5(HATCHEE_DIR, "daemon.log");
12572
12930
  var LABEL = "cloud.hatchee.daemon";
12573
- var PLIST = join4(HOME, "Library", "LaunchAgents", `${LABEL}.plist`);
12574
- var UNIT = join4(HOME, ".config", "systemd", "user", "hatchee.service");
12931
+ var PLIST = join5(HOME, "Library", "LaunchAgents", `${LABEL}.plist`);
12932
+ var UNIT = join5(HOME, ".config", "systemd", "user", "hatchee.service");
12575
12933
  function servicePath(node) {
12576
12934
  const system = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"];
12577
12935
  const userTool = [
12578
12936
  dirname(node),
12579
12937
  "/opt/homebrew/bin",
12580
12938
  "/usr/local/bin",
12581
- join4(HOME, ".bun/bin"),
12582
- join4(HOME, ".npm-global/bin"),
12583
- join4(HOME, ".local/bin")
12939
+ join5(HOME, ".bun/bin"),
12940
+ join5(HOME, ".npm-global/bin"),
12941
+ join5(HOME, ".local/bin")
12584
12942
  ];
12585
12943
  const seen = new Set;
12586
12944
  return [...system, ...userTool].filter((p) => p && !seen.has(p) && seen.add(p)).join(":");
@@ -12731,12 +13089,12 @@ function uninstallService() {
12731
13089
  if (process.platform === "darwin") {
12732
13090
  const uid = String(process.getuid?.() ?? "");
12733
13091
  run("launchctl", ["bootout", `gui/${uid}/${LABEL}`]);
12734
- if (existsSync4(PLIST))
13092
+ if (existsSync5(PLIST))
12735
13093
  rmSync(PLIST);
12736
13094
  console.log(` ✓ background service removed (launchd: ${LABEL})`);
12737
13095
  } else if (process.platform === "linux") {
12738
13096
  run("systemctl", ["--user", "disable", "--now", "hatchee.service"]);
12739
- if (existsSync4(UNIT)) {
13097
+ if (existsSync5(UNIT)) {
12740
13098
  rmSync(UNIT);
12741
13099
  run("systemctl", ["--user", "daemon-reload"]);
12742
13100
  }
@@ -12744,7 +13102,7 @@ function uninstallService() {
12744
13102
  } else {
12745
13103
  console.log(` no background service on ${process.platform}.`);
12746
13104
  }
12747
- if (existsSync4(STABLE_BIN))
13105
+ if (existsSync5(STABLE_BIN))
12748
13106
  rmSync(STABLE_BIN);
12749
13107
  }
12750
13108
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hatchee",
3
- "version": "0.2.0",
3
+ "version": "0.3.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": {