heyterm 0.1.2 → 0.1.3

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.
@@ -1,9 +1,9 @@
1
1
  // src/server/cli.ts
2
- import { execFile as execFile5, spawn as spawn2 } from "node:child_process";
3
- import { readFile as readFile8 } from "node:fs/promises";
4
- import os8 from "node:os";
5
- import path19 from "node:path";
6
- import { promisify as promisify5 } from "node:util";
2
+ import { execFile as execFile8, spawn as spawn2 } from "node:child_process";
3
+ import { readFile as readFile11 } from "node:fs/promises";
4
+ import os9 from "node:os";
5
+ import path24 from "node:path";
6
+ import { promisify as promisify6 } from "node:util";
7
7
 
8
8
  // src/server/agents/agent-adapters.ts
9
9
  var permissionHookSeconds = 595;
@@ -116,6 +116,10 @@ var HookCommands = class {
116
116
  static isHeyTerm(command) {
117
117
  return [forwardScriptName, ...olderScriptNames].some((name) => command.includes(name));
118
118
  }
119
+ // A hook of this copy of HeyTerm (its own forwarder), or one left by HeyTerm's old name.
120
+ static belongsTo(command, scriptPath) {
121
+ return command.includes(quoted(scriptPath)) || olderScriptNames.some((name) => command.includes(name));
122
+ }
119
123
  };
120
124
  function quoted(scriptPath) {
121
125
  if (/["$`%!\r\n]/.test(scriptPath)) throw new Error(`HeyTerm's data folder path cannot be used in a hook command: ${scriptPath}`);
@@ -307,10 +311,11 @@ var ExistingSessionConnector = class {
307
311
  };
308
312
 
309
313
  // src/server/main.ts
310
- import { execFile as execFile3 } from "node:child_process";
311
- import os6 from "node:os";
312
- import path17 from "node:path";
313
- import { promisify as promisify3 } from "node:util";
314
+ import { execFile as execFile6 } from "node:child_process";
315
+ import { readFile as readFile9 } from "node:fs/promises";
316
+ import os7 from "node:os";
317
+ import path22 from "node:path";
318
+ import { promisify as promisify4 } from "node:util";
314
319
 
315
320
  // src/server/actions/action-executor.ts
316
321
  import { EventEmitter } from "node:events";
@@ -383,7 +388,13 @@ function stringField(record2, key) {
383
388
  var statesWaitingOnUser = ["needs_permission", "asked", "needs_trust", "done", "failed"];
384
389
  var SessionLifecycle = class _SessionLifecycle {
385
390
  static maximumActivityEntries = 200;
391
+ // Every event is also activity: it says when the session last did anything, clears a quiet
392
+ // notice, and tracks the shell command running, if any (see quiet-rules.ts).
386
393
  static apply(original, event, options = {}) {
394
+ const result = _SessionLifecycle.applyEvent(original, event, options);
395
+ return { ...result, lastActivityAt: event.receivedAt, quiet: void 0, runningCommand: runningCommandAfter(result, event) };
396
+ }
397
+ static applyEvent(original, event, options) {
387
398
  const session = original.blockedByTerminalDialog && event.name !== "Notification" ? { ...original, blockedByTerminalDialog: void 0 } : original;
388
399
  const payload = event.payload;
389
400
  switch (event.name) {
@@ -391,13 +402,13 @@ var SessionLifecycle = class _SessionLifecycle {
391
402
  return _SessionLifecycle.moveTo({ ...session, agentSessionId: stringField2(payload, "session_id") || session.agentSessionId, transcriptPath: stringField2(payload, "transcript_path") || session.transcriptPath }, "idle", event.receivedAt);
392
403
  case "UserPromptSubmit": {
393
404
  const prompt = stringField2(payload, "prompt");
394
- const withPrompt = _SessionLifecycle.addActivity({ ...session, lastPrompt: prompt, pending: void 0 }, "prompt", prompt, event.receivedAt);
405
+ const withPrompt = _SessionLifecycle.addActivity({ ...session, lastPrompt: prompt, pending: void 0, filesChangedInLastTask: [] }, "prompt", prompt, event.receivedAt);
395
406
  return _SessionLifecycle.moveTo(withPrompt, "working", event.receivedAt);
396
407
  }
397
408
  case "PreToolUse":
398
409
  return _SessionLifecycle.applyToolStart(session, event);
399
410
  case "PostToolUse":
400
- return _SessionLifecycle.applyToolEnd(session, event);
411
+ return _SessionLifecycle.applyToolEnd(_SessionLifecycle.withFileChanged(session, event), event);
401
412
  case "PermissionRequest":
402
413
  return _SessionLifecycle.applyPermissionRequest(session, event, options.requestId ?? `request-${event.receivedAt}`);
403
414
  case "Stop":
@@ -418,6 +429,10 @@ var SessionLifecycle = class _SessionLifecycle {
418
429
  static applyTrustDialog(session, now, trustSubject = "folder") {
419
430
  return _SessionLifecycle.moveTo({ ...session, trustSubject }, "needs_trust", now);
420
431
  }
432
+ // Claude Code was stopped mid-turn (no Stop hook is sent for that) and waits at its prompt.
433
+ static applyTurnStopped(session, now) {
434
+ return _SessionLifecycle.moveTo({ ...session, pending: void 0, blockedByTerminalDialog: void 0 }, "idle", now);
435
+ }
421
436
  static markSeen(session, now) {
422
437
  return { ...session, lastSeenAt: now, activitySinceSeen: [] };
423
438
  }
@@ -432,6 +447,13 @@ var SessionLifecycle = class _SessionLifecycle {
432
447
  if (withActivity.state === "working") return withActivity;
433
448
  return _SessionLifecycle.moveTo(withActivity, "working", event.receivedAt);
434
449
  }
450
+ // An edit that finished, so the file really changed (a denied one never gets here).
451
+ static withFileChanged(session, event) {
452
+ const change = describeToolUse(stringField2(event.payload, "tool_name"), recordField(event.payload, "tool_input"), session.projectPath);
453
+ if (change?.kind !== "file" || !change.text) return session;
454
+ const files = session.filesChangedInLastTask ?? [];
455
+ return files.includes(change.text) ? session : { ...session, filesChangedInLastTask: [...files, change.text] };
456
+ }
435
457
  static applyToolEnd(session, event) {
436
458
  if (!session.pending) return session;
437
459
  const toolName = stringField2(event.payload, "tool_name");
@@ -468,6 +490,13 @@ var SessionLifecycle = class _SessionLifecycle {
468
490
  return { ...session, activitySinceSeen: entries.slice(-_SessionLifecycle.maximumActivityEntries) };
469
491
  }
470
492
  };
493
+ function runningCommandAfter(session, event) {
494
+ const toolName = stringField2(event.payload, "tool_name");
495
+ if (event.name === "PreToolUse" && toolName === "Bash") return { text: stringField2(recordField(event.payload, "tool_input"), "command"), since: event.receivedAt };
496
+ if (event.name === "PostToolUse" && toolName === "Bash") return void 0;
497
+ if (["UserPromptSubmit", "Stop", "StopFailure", "SessionEnd"].includes(event.name)) return void 0;
498
+ return session.runningCommand;
499
+ }
471
500
  function describeToolUse(toolName, toolInput, projectPath) {
472
501
  if (toolName === "Bash") return { kind: "tool", text: stringField2(toolInput, "command") };
473
502
  if (["Edit", "Write", "MultiEdit", "NotebookEdit"].includes(toolName)) {
@@ -628,6 +657,54 @@ var UtteranceLimits = class {
628
657
  }
629
658
  };
630
659
 
660
+ // src/server/actions/session-actions.ts
661
+ import { execFile } from "node:child_process";
662
+ import { readFile as readFile3 } from "node:fs/promises";
663
+ import path5 from "node:path";
664
+ import { promisify } from "node:util";
665
+
666
+ // src/shared/change-summary.ts
667
+ var filesNamedAtMost = 3;
668
+ var ChangeSummary = class {
669
+ static describe(sessionName, files, lineCounts) {
670
+ if (files.length === 0) return { files, spoken: `${sessionName} has not changed any files in its last task.` };
671
+ const fileWord = files.length === 1 ? "file" : "files";
672
+ const opening = `${sessionName} changed ${files.length} ${fileWord} in its last task`;
673
+ if (files.length > filesNamedAtMost) return { files, spoken: `${opening}, most of them in ${mostCommonFolder(files)}.` };
674
+ const names = joinedAsList(files.map((file) => file.split("/").pop() ?? file));
675
+ return { files, spoken: `${opening}: ${names}.${lineSentence(files, lineCounts)}` };
676
+ }
677
+ static parseNumstat(output) {
678
+ const counts = /* @__PURE__ */ new Map();
679
+ for (const line of output.split("\n")) {
680
+ const [added, removed, file] = line.split(" ");
681
+ if (!file) continue;
682
+ counts.set(file, { added: Number(added) || 0, removed: Number(removed) || 0 });
683
+ }
684
+ return counts;
685
+ }
686
+ };
687
+ function lineSentence(files, lineCounts) {
688
+ if (!lineCounts) return "";
689
+ const known = files.map((file) => lineCounts.get(file)).filter((counts) => counts !== void 0);
690
+ if (known.length === 0) return "";
691
+ const added = known.reduce((total, counts) => total + counts.added, 0);
692
+ const removed = known.reduce((total, counts) => total + counts.removed, 0);
693
+ return ` Since the last commit, ${added} ${added === 1 ? "line" : "lines"} added and ${removed} removed.`;
694
+ }
695
+ function mostCommonFolder(files) {
696
+ const counts = /* @__PURE__ */ new Map();
697
+ for (const file of files) {
698
+ const folder = file.includes("/") ? file.slice(0, file.lastIndexOf("/")) : "the top folder";
699
+ counts.set(folder, (counts.get(folder) ?? 0) + 1);
700
+ }
701
+ return [...counts].sort((first, second) => second[1] - first[1])[0][0];
702
+ }
703
+ function joinedAsList(items) {
704
+ if (items.length <= 1) return items.join("");
705
+ return `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
706
+ }
707
+
631
708
  // src/shared/message-text.ts
632
709
  var longestSpokenSentence = 160;
633
710
  function firstSentence(message) {
@@ -726,6 +803,8 @@ var SessionActions = class {
726
803
  return this.trust(action.session);
727
804
  case "status":
728
805
  return this.status(action.session);
806
+ case "changes":
807
+ return { text: (await this.changes(action.session)).spoken, tone: "info", speak: true };
729
808
  default:
730
809
  return { text: "I did not catch what to do.", tone: "warning", speak: true };
731
810
  }
@@ -737,6 +816,36 @@ var SessionActions = class {
737
816
  if (result === "queued") return { text: `Queued for ${sessionName}; it reads it after its current step.`, tone: "info", speak: false };
738
817
  return { text: `${sessionName} did not take the message. Check its terminal.`, tone: "error", speak: true };
739
818
  }
819
+ // The files the session's own edits touched in its last task, with git's line counts where git has them.
820
+ async changes(sessionName) {
821
+ const session = this.registry.get(sessionName) ?? this.registry.recentlyClosedSessions().find((closed) => closed.name === sessionName);
822
+ if (!session) return { files: [], spoken: `There is no session called ${sessionName}.` };
823
+ const files = session.filesChangedInLastTask ?? [];
824
+ if (files.length === 0) return ChangeSummary.describe(sessionName, files, void 0);
825
+ const numstat = await runGit(session.projectPath, ["diff", "--numstat", "--relative", "HEAD", "--", ...files]);
826
+ if (numstat === void 0) return ChangeSummary.describe(sessionName, files, void 0);
827
+ const counts = ChangeSummary.parseNumstat(numstat);
828
+ for (const file of files) {
829
+ if (counts.has(file)) continue;
830
+ const text2 = await readFile3(path5.join(session.projectPath, file), "utf8").catch(() => void 0);
831
+ if (text2 !== void 0 && text2.length < 1e6) counts.set(file, { added: text2.split("\n").filter((line, index, lines) => line !== "" || index < lines.length - 1).length, removed: 0 });
832
+ }
833
+ return ChangeSummary.describe(sessionName, files, counts);
834
+ }
835
+ // Off the list, and its worktree with it when nothing in the worktree would be lost.
836
+ async forgetClosed(sessionName) {
837
+ const closed = this.registry.recentlyClosedSessions().find((session) => session.name === sessionName);
838
+ if (!closed || !this.registry.forgetClosed(sessionName)) return { text: `There is no closed session called ${sessionName}.`, tone: "warning", speak: false };
839
+ const removed = `Removed ${sessionName} from the list. Its conversation is still there to resume.`;
840
+ if (!closed.worktree) return { text: removed, tone: "info", speak: false };
841
+ const worktree = await this.launcher.removeWorktreeIfUnused(closed.worktree);
842
+ if (worktree === "kept_uncommitted") return { text: `${removed} Its worktree has uncommitted changes, so it stays at ${closed.worktree.worktreePath}.`, tone: "info", speak: false };
843
+ if (worktree === "kept_commits") return { text: `${removed} Its branch ${closed.worktree.branch} has commits of its own, so the worktree stays at ${closed.worktree.worktreePath}.`, tone: "info", speak: false };
844
+ return { text: `${removed} Its worktree is deleted; nothing in it was lost.`, tone: "info", speak: false };
845
+ }
846
+ rename(sessionName, newName) {
847
+ return this.launcher.rename(sessionName, newName);
848
+ }
740
849
  async close(sessionName) {
741
850
  await this.launcher.close(sessionName);
742
851
  await this.audit.record({ session: sessionName, decision: "closed", subject: sessionName, decidedBy: "voice or click" });
@@ -765,6 +874,11 @@ var SessionActions = class {
765
874
  }
766
875
  focus(session.name);
767
876
  const taskNote = action.task ? " It gets the task once it is ready." : "";
877
+ if (session.worktree) {
878
+ return { text: `Starting ${session.name} in a copy of the folder of its own, on a new branch, so it and the other session there cannot change each other's files. It starts from the last commit.${taskNote}`, tone: "success", speak: true };
879
+ }
880
+ const sharing = this.registry.list().find((other) => other.name !== session.name && other.projectPath === session.projectPath);
881
+ if (sharing) return { text: `Starting ${session.name}. It shares ${sharing.name}'s folder, which is not a git repository, so the two can change each other's files.${taskNote}`, tone: "warning", speak: true };
768
882
  return { text: `Starting ${session.name}.${taskNote}`, tone: "success", speak: false };
769
883
  }
770
884
  async resume(sessionName, focus) {
@@ -794,9 +908,16 @@ var SessionActions = class {
794
908
  return { text: `${digest.spoken}${prompt}`, tone: "info", speak: true };
795
909
  }
796
910
  };
911
+ var execFileAsync = promisify(execFile);
912
+ function runGit(folder, args) {
913
+ return execFileAsync("git", ["-C", folder, ...args], { timeout: 5e3 }).then(
914
+ ({ stdout }) => stdout,
915
+ () => void 0
916
+ );
917
+ }
797
918
 
798
919
  // src/shared/attention.ts
799
- var kindOrder = ["permission", "question", "trust", "failed", "finished"];
920
+ var kindOrder = ["permission", "question", "trust", "failed", "finished", "quiet"];
800
921
  var kindsSpokenInQuietMode = ["permission", "question"];
801
922
  var AttentionQueue = class {
802
923
  static build(sessions) {
@@ -813,6 +934,14 @@ var AnnouncementText = class {
813
934
  return `${capitalize(countWord(spoken.length))} sessions need you. ${spoken.map((item) => item.spoken).join(" ")}`;
814
935
  }
815
936
  };
937
+ function quietItem(session, quiet) {
938
+ const base = { sessionName: session.name, since: quiet.since, kind: "quiet" };
939
+ if (quiet.kind === "long_command") {
940
+ const command = session.runningCommand?.text;
941
+ return { ...base, headline: `${session.name} has been running one command for 10 minutes${command ? `: ${command}` : ""}`, spoken: `${session.name} has been running one command for 10 minutes.` };
942
+ }
943
+ return { ...base, headline: `${session.name} has gone quiet: nothing from it for 5 minutes`, spoken: `${session.name} has gone quiet for 5 minutes. It may be stuck.` };
944
+ }
816
945
  function attentionItemFor(session) {
817
946
  const base = { sessionName: session.name, since: session.stateSince };
818
947
  const pending = session.pending;
@@ -841,8 +970,13 @@ function attentionItemFor(session) {
841
970
  case "done": {
842
971
  if (session.lastSeenAt >= session.stateSince) return void 0;
843
972
  const lead = firstSentence(session.lastMessage ?? "");
844
- return { ...base, kind: "finished", headline: lead ? `${session.name} finished: ${lead}` : `${session.name} finished`, spoken: `${session.name} is done.` };
973
+ const changedCount = session.filesChangedInLastTask?.length ?? 0;
974
+ const changed = changedCount > 0 ? `${changedCount} ${changedCount === 1 ? "file" : "files"}` : "";
975
+ const headline2 = `${session.name} finished${lead ? `: ${lead}` : ""}${changed ? `${lead ? "" : "."} Changed ${changed}.` : ""}`;
976
+ return { ...base, kind: "finished", headline: headline2, spoken: changed ? `${session.name} is done. It changed ${changed}.` : `${session.name} is done.` };
845
977
  }
978
+ case "working":
979
+ return session.quiet ? quietItem(session, session.quiet) : void 0;
846
980
  default:
847
981
  return void 0;
848
982
  }
@@ -916,6 +1050,22 @@ var RequestThread = class _RequestThread {
916
1050
  }
917
1051
  };
918
1052
 
1053
+ // src/shared/session-names.ts
1054
+ var longestName = 40;
1055
+ var SessionNames = class {
1056
+ static fromTyped(text2) {
1057
+ return text2.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1058
+ }
1059
+ // Why a session cannot take this name, or undefined when it can.
1060
+ static renameProblem(newName, currentName, takenNames) {
1061
+ if (!newName) return "Give it a name with a letter or a number.";
1062
+ if (newName.length > longestName) return `Keep it to ${longestName} characters.`;
1063
+ if (newName === currentName) return `It is already called ${newName}.`;
1064
+ if (takenNames.includes(newName)) return `There is already a session called ${newName}.`;
1065
+ return void 0;
1066
+ }
1067
+ };
1068
+
919
1069
  // src/server/router/utterance-text.ts
920
1070
  function wordSpans(text2) {
921
1071
  return [...text2.matchAll(/[a-z0-9][a-z0-9'-]*/gi)].map((match) => ({
@@ -1131,6 +1281,7 @@ function denialReason(utterance) {
1131
1281
  var sessionCommands = [
1132
1282
  { pattern: /^(?:what's|what is|how's|how is) (.+?) (?:doing|up to)$/, build: (session) => ({ type: "status", session }) },
1133
1283
  { pattern: /^status (?:of|for) (.+)$/, build: (session) => ({ type: "status", session }) },
1284
+ { pattern: /^what (?:did|has) (.+?) (?:change|changed|edit|edited)$/, build: (session) => ({ type: "changes", session }) },
1134
1285
  { pattern: /^(?:go to|show me|show|switch to|focus on|focus|look at|bring up|open) (.+)$/, build: (session) => ({ type: "focus", session }) },
1135
1286
  { pattern: /^(?:close|kill|end|shut down|quit|exit) (.+)$/, build: (session) => ({ type: "close", session, confirmed: false }) },
1136
1287
  { pattern: /^(?:stop|interrupt|pause|halt|hold) (.+)$/, build: (session) => ({ type: "interrupt", session }) },
@@ -1148,7 +1299,7 @@ var FastPathRouter = class {
1148
1299
  if (!phrase) return void 0;
1149
1300
  const spans = wordSpans(utterance);
1150
1301
  const sessionMatcher = new SpokenNameMatcher(context.sessions.map((session) => session.name));
1151
- return this.routeControlPhrase(phrase, context) ?? this.routeSpawn(utterance, spans, context) ?? this.routeSessionCommand(phrase, sessionMatcher, context) ?? this.routeTell(utterance, spans, sessionMatcher) ?? new HeadAnswerRouter().route(utterance, phrase, context, sessionMatcher) ?? this.routeAddressed(utterance, spans, sessionMatcher) ?? (this.options.sendUnaddressedWordsToFocusedSession ? this.routeToFocusedSession(utterance, phrase, context, sessionMatcher) : void 0);
1302
+ return this.routeControlPhrase(phrase, context) ?? this.routeSpawn(utterance, spans, context) ?? this.routeRename(phrase, sessionMatcher, context) ?? this.routeSessionCommand(phrase, sessionMatcher, context) ?? this.routeTell(utterance, spans, sessionMatcher) ?? new HeadAnswerRouter().route(utterance, phrase, context, sessionMatcher) ?? this.routeAddressed(utterance, spans, sessionMatcher) ?? (this.options.sendUnaddressedWordsToFocusedSession ? this.routeToFocusedSession(utterance, phrase, context, sessionMatcher) : void 0);
1152
1303
  }
1153
1304
  routeControlPhrase(phrase, context) {
1154
1305
  if (context.hasPendingConfirmation) {
@@ -1189,6 +1340,15 @@ var FastPathRouter = class {
1189
1340
  }
1190
1341
  return void 0;
1191
1342
  }
1343
+ // "rename synqed to synqed auth": an open session first, then a recently closed one.
1344
+ routeRename(phrase, sessionMatcher, context) {
1345
+ const match = /^rename (.+?) (?:to|as) (.+)$/.exec(phrase);
1346
+ if (!match?.[1] || !match[2]) return void 0;
1347
+ const spokenName = withoutNameFiller(match[1].split(" ")).join(" ");
1348
+ const session = sessionMatcher.findBest(spokenName) ?? new SpokenNameMatcher(context.recentlyClosedNames ?? []).findBest(spokenName);
1349
+ if (!session) return void 0;
1350
+ return { type: "rename", session: session.name, newName: SessionNames.fromTyped(match[2]) };
1351
+ }
1192
1352
  routeSessionCommand(phrase, sessionMatcher, context) {
1193
1353
  for (const command of sessionCommands) {
1194
1354
  const match = command.pattern.exec(phrase);
@@ -1295,11 +1455,10 @@ var ActionExecutor = class extends EventEmitter {
1295
1455
  mode = "speak";
1296
1456
  focusedSession;
1297
1457
  routing = false;
1298
- lastHeard;
1299
1458
  controls = new PendingControls(() => this.emit("changed"));
1300
1459
  assistant;
1301
1460
  controlState() {
1302
- return { ...this.controls.view(), mode: this.mode, focusedSession: this.focusedSession, routing: this.routing, lastHeard: this.lastHeard };
1461
+ return { ...this.controls.view(), mode: this.mode, focusedSession: this.focusedSession, routing: this.routing };
1303
1462
  }
1304
1463
  setFocus(sessionName) {
1305
1464
  if (!this.registry.get(sessionName)) return;
@@ -1331,8 +1490,6 @@ var ActionExecutor = class extends EventEmitter {
1331
1490
  return;
1332
1491
  }
1333
1492
  const utterance = checked.utterance;
1334
- this.lastHeard = utterance;
1335
- this.emit("changed");
1336
1493
  const context = await this.routingContext(focusOverride);
1337
1494
  const action = this.fastPath.route(utterance, context);
1338
1495
  if (!action || !ConductorRouting.handledInstantly(action, { confirmationWaiting: this.controls.view().confirmation !== void 0 })) {
@@ -1369,6 +1526,12 @@ var ActionExecutor = class extends EventEmitter {
1369
1526
  return this.confirm();
1370
1527
  case "close":
1371
1528
  return this.close(action.session, action.confirmed);
1529
+ case "forget_closed":
1530
+ return this.tell(await this.sessionActions.forgetClosed(action.session));
1531
+ case "rename": {
1532
+ const result = this.rename(action.session, action.newName);
1533
+ return this.tell({ text: result.text, tone: result.renamed ? "success" : "warning", speak: !result.renamed });
1534
+ }
1372
1535
  case "choose":
1373
1536
  return this.choose(action.index);
1374
1537
  case "clarify":
@@ -1445,6 +1608,13 @@ var ActionExecutor = class extends EventEmitter {
1445
1608
  if (this.focusedSession === sessionName) this.focusedSession = void 0;
1446
1609
  this.tell(await this.sessionActions.close(sessionName));
1447
1610
  }
1611
+ // Also the assistant's rename_session tool, which reads the result back.
1612
+ rename(sessionName, newName) {
1613
+ const problem = this.sessionActions.rename(sessionName, newName);
1614
+ if (problem) return { renamed: false, text: problem };
1615
+ if (this.focusedSession === sessionName) this.setFocus(newName);
1616
+ return { renamed: true, text: `Renamed ${sessionName} to ${newName}.` };
1617
+ }
1448
1618
  async confirm() {
1449
1619
  const confirmed = this.controls.takeConfirmation();
1450
1620
  if (!confirmed) return this.tell({ text: "Nothing is waiting for a confirm.", tone: "info", speak: true });
@@ -1531,11 +1701,11 @@ How to reply:
1531
1701
  - Never read a command, file path or code aloud. Say what it does: "push the HeyTerm fix to GitHub", not the git command.
1532
1702
 
1533
1703
  What you can do:
1534
- - See every session in <situation>, read a session's screen and full last message (read_session), send it work (send_to_session), answer its multiple-choice questions (answer_question), approve or deny what it asks permission for, start, close, bring back or interrupt sessions, and bring one onto the screen (show_session).
1535
- - Run shell commands yourself with Bash and read files, for quick look-ups: git status, what changed, test output, what is running, anything on this Mac.
1704
+ - See every session in <situation>, read a session's screen and full last message (read_session), send it work (send_to_session), answer its multiple-choice questions (answer_question), approve or deny what it asks permission for, start, close, bring back, interrupt or rename sessions (rename_session), say what one changed in its last task (session_changes, before you describe what a session did), and bring one onto the screen (show_session).
1705
+ - Anything Claude Code can do on this Mac, yourself: run any shell command, install tools, write and edit files and scripts, search and read the web (WebSearch, WebFetch), use the developer's MCP servers and skills, and look at the screen when asked about it (screencapture -x to a file in your scratchpad, then Read the image).
1536
1706
 
1537
1707
  How to decide:
1538
- - Coding work in a project goes to that project's session. If the project has none, start one with the task. Sessions can start in any folder on this Mac, not only listed projects: pass its path. If it is unclear which session or folder the developer means, ask.
1708
+ - Coding work in a project goes to that project's session, so the developer can follow and approve it; if they ask you to do it yourself, do it. If the project has none, start one with the task. A second session in a folder another open session uses gets a git worktree and branch of its own (read_session shows it); when it finishes, say its work is on that branch for the developer to merge. Sessions can start in any folder on this Mac, not only listed projects: pass its path. If it is unclear which session or folder the developer means, ask.
1539
1709
  - Answer questions yourself when you can, by reading sessions or running a command.
1540
1710
  - Approve or deny only when the developer clearly said so. The agent asked, so their yes is enough, high risk or not; mention the risk when you tell them about it.
1541
1711
  - Never do anything destructive on your own initiative. When the developer asks for something, do it: if Claude Code's permission mode needs their OK, HeyTerm asks them out loud, so do not ask them yourself first.
@@ -1551,7 +1721,10 @@ var ConductorTurnText = class {
1551
1721
  The developer said: "${utterance}"`;
1552
1722
  }
1553
1723
  static forEvent(items, situation) {
1554
- const lines = items.map((item) => `- ${item.kind}: ${item.sessionName} (${item.headline.replaceAll("<", "\u2039")}). Plain line: ${item.spoken}`);
1724
+ const lines = items.map((item) => {
1725
+ const line = `- ${item.kind}: ${item.sessionName} (${item.headline.replaceAll("<", "\u2039")}). Plain line: ${item.spoken}`;
1726
+ return item.kind === "finished" || item.kind === "failed" ? `${line}${outcomeDetail(situation.sessions.find((session) => session.name === item.sessionName))}` : line;
1727
+ });
1555
1728
  return `${situationBlock(situation)}
1556
1729
 
1557
1730
  <event>
@@ -1560,6 +1733,14 @@ ${lines.join("\n")}
1560
1733
  Tell the developer.`;
1561
1734
  }
1562
1735
  };
1736
+ function outcomeDetail(session) {
1737
+ if (!session) return "";
1738
+ const reply2 = session.lastMessage ? `
1739
+ Its whole reply: <session_text>${session.lastMessage.replaceAll("</session_text>", "")}</session_text>` : "";
1740
+ const files = session.filesChangedInLastTask?.length ? `
1741
+ Files it changed in this task: ${session.filesChangedInLastTask.join(", ")}` : "";
1742
+ return `${reply2}${files}`;
1743
+ }
1563
1744
  function situationBlock(situation) {
1564
1745
  const sessions = situation.sessions.map(describeSessionForAssistant).join("\n") || "none";
1565
1746
  return [
@@ -1714,6 +1895,11 @@ var Assistant = class extends EventEmitter2 {
1714
1895
  this.record("error", message);
1715
1896
  if (this.announcementMode() !== "mute") this.emit("say", "Something went wrong on my side. It's on screen.");
1716
1897
  }
1898
+ // Lines kept from before a restart; new ones number on from them.
1899
+ restoreEntries(entries) {
1900
+ this.entries = entries.slice(-keptEntries);
1901
+ this.nextEntryId = Math.max(0, ...this.entries.map((entry) => entry.id)) + 1;
1902
+ }
1717
1903
  record(kind, text2, request) {
1718
1904
  const entry = request ? { id: this.nextEntryId++, at: Date.now(), kind, text: text2, request } : { id: this.nextEntryId++, at: Date.now(), kind, text: text2 };
1719
1905
  this.entries = [...this.entries, entry].slice(-keptEntries);
@@ -1840,7 +2026,9 @@ var Conductor = class extends EventEmitter3 {
1840
2026
  pathToClaudeCodeExecutable: claudeExecutablePath(),
1841
2027
  // The user's own setup: their MCP servers, skills, CLAUDE.md and permission rules.
1842
2028
  settingSources: ["user"],
1843
- persistSession: false,
2029
+ // Saved like any Claude Code conversation, so it can be resumed by id.
2030
+ persistSession: true,
2031
+ ...this.options.memory?.sessionId() ? { resume: this.options.memory.sessionId() } : {},
1844
2032
  tools: { type: "preset", preset: "claude_code" },
1845
2033
  mcpServers: { heyterm: this.options.tools },
1846
2034
  // The mode the developer's own Claude Code sessions run in (their plan's default), so the
@@ -1859,8 +2047,13 @@ var Conductor = class extends EventEmitter3 {
1859
2047
  void this.readSession(this.session);
1860
2048
  }
1861
2049
  async readSession(session) {
2050
+ let started = false;
1862
2051
  try {
1863
2052
  for await (const message of session) {
2053
+ if (message.type === "system" && message.subtype === "init") {
2054
+ started = true;
2055
+ this.options.memory?.rememberSession(message.session_id);
2056
+ }
1864
2057
  if (this.session !== session) return;
1865
2058
  if (message.type === "assistant") {
1866
2059
  for (const block of message.message.content) {
@@ -1874,6 +2067,7 @@ var Conductor = class extends EventEmitter3 {
1874
2067
  }
1875
2068
  } catch {
1876
2069
  } finally {
2070
+ if (!started) this.options.memory?.forgetSession();
1877
2071
  if (this.session === session) {
1878
2072
  this.session = void 0;
1879
2073
  this.finishTurn(void 0, new Error("The assistant stopped unexpectedly."));
@@ -1930,6 +2124,7 @@ function createConductorTools(dependencies) {
1930
2124
  const lastLines = screen.split("\n").filter((line) => line.trim()).slice(-screenLinesShown).join("\n");
1931
2125
  return [
1932
2126
  `State: ${session.state}. Project folder: ${session.projectPath}.`,
2127
+ session.worktree ? `Works in its own git worktree on branch ${session.worktree.branch}, made from ${session.worktree.repoPath}.` : "",
1933
2128
  session.lastPrompt ? `Last task: <session_text>${session.lastPrompt}</session_text>` : "",
1934
2129
  session.lastMessage ? `Last message: <session_text>${session.lastMessage}</session_text>` : "",
1935
2130
  session.pending ? `Waiting on: <session_text>${JSON.stringify(session.pending.kind === "permission" ? { permission: session.pending.summary, risk: session.pending.risk } : { questions: session.pending.questions })}</session_text>` : "",
@@ -2014,6 +2209,16 @@ function createConductorTools(dependencies) {
2014
2209
  { name: z.string() },
2015
2210
  ({ name }) => withSession(name, async () => (await sessionActions.perform({ type: "trust", session: name }, () => void 0)).text)
2016
2211
  ),
2212
+ tool("session_changes", "List the files a session changed in its last task, from its own edits, with line counts from git. Use it before saying what a session did.", { name: z.string() }, async ({ name }) => {
2213
+ const summary = await sessionActions.changes(name);
2214
+ return reply([summary.spoken, ...summary.files].join("\n"));
2215
+ }),
2216
+ tool(
2217
+ "rename_session",
2218
+ "Rename an open or recently closed session, so the developer can tell it apart. Works for sessions in their own terminal too.",
2219
+ { name: z.string(), new_name: z.string() },
2220
+ async ({ name, new_name }) => reply(executor.rename(name, SessionNames.fromTyped(new_name)).text)
2221
+ ),
2017
2222
  tool(
2018
2223
  "show_session",
2019
2224
  "Bring a session onto the developer's screen.",
@@ -2058,9 +2263,19 @@ function createAssistant(dependencies) {
2058
2263
  workingDirectory: dependencies.workingDirectory,
2059
2264
  tools: createConductorTools(dependencies),
2060
2265
  beforeTool,
2061
- askDeveloper
2266
+ askDeveloper,
2267
+ ...dependencies.memory ? { memory: dependencies.memory } : {}
2062
2268
  });
2063
2269
  assistant = new Assistant(conductor, () => executor.situation(), () => executor.currentMode());
2270
+ const memory = dependencies.memory;
2271
+ if (memory) {
2272
+ assistant.restoreEntries(memory.entries());
2273
+ let saveTimer;
2274
+ assistant.on("conversation", (entries) => {
2275
+ clearTimeout(saveTimer);
2276
+ saveTimer = setTimeout(() => memory.rememberEntries(entries), 500);
2277
+ });
2278
+ }
2064
2279
  return assistant;
2065
2280
  }
2066
2281
  function spokenToolUse(toolName) {
@@ -2072,14 +2287,60 @@ function spokenToolUse(toolName) {
2072
2287
  return `use ${toolName}`;
2073
2288
  }
2074
2289
 
2290
+ // src/server/conductor/assistant-memory.ts
2291
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
2292
+ import path6 from "node:path";
2293
+ var AssistantMemory = class {
2294
+ filePath;
2295
+ remembered;
2296
+ constructor(dataDirectory2) {
2297
+ this.filePath = path6.join(dataDirectory2, "assistant.json");
2298
+ this.remembered = readRemembered(this.filePath);
2299
+ }
2300
+ sessionId() {
2301
+ return this.remembered.sessionId;
2302
+ }
2303
+ entries() {
2304
+ return this.remembered.entries ?? [];
2305
+ }
2306
+ rememberSession(sessionId) {
2307
+ if (this.remembered.sessionId === sessionId) return;
2308
+ this.write({ ...this.remembered, sessionId });
2309
+ }
2310
+ forgetSession() {
2311
+ const { sessionId: _forgotten, ...rest } = this.remembered;
2312
+ this.write(rest);
2313
+ }
2314
+ rememberEntries(entries) {
2315
+ this.write({ ...this.remembered, entries });
2316
+ }
2317
+ write(remembered) {
2318
+ this.remembered = remembered;
2319
+ const temporaryPath = `${this.filePath}.tmp`;
2320
+ try {
2321
+ writeFileSync(temporaryPath, JSON.stringify(remembered), { mode: 384 });
2322
+ renameSync(temporaryPath, this.filePath);
2323
+ } catch {
2324
+ }
2325
+ }
2326
+ };
2327
+ function readRemembered(filePath) {
2328
+ try {
2329
+ const parsed = JSON.parse(readFileSync(filePath, "utf8"));
2330
+ return typeof parsed === "object" && parsed !== null ? parsed : {};
2331
+ } catch {
2332
+ return {};
2333
+ }
2334
+ }
2335
+
2075
2336
  // src/server/config.ts
2076
2337
  import { existsSync } from "node:fs";
2077
2338
  import os2 from "node:os";
2078
- import path7 from "node:path";
2339
+ import path9 from "node:path";
2079
2340
 
2080
2341
  // src/server/setup/data-folder.ts
2081
2342
  import { chmod as chmod2, mkdir as mkdir3, rename as rename2, stat } from "node:fs/promises";
2082
- import path5 from "node:path";
2343
+ import path7 from "node:path";
2083
2344
  var DataFolder = class {
2084
2345
  // Readable by this user alone: it holds the token, the audit log and backups of Claude settings.
2085
2346
  static async ensurePrivate(folder) {
@@ -2087,8 +2348,8 @@ var DataFolder = class {
2087
2348
  if (process.platform !== "win32") await chmod2(folder, 448);
2088
2349
  }
2089
2350
  static async moveFromOldName(home) {
2090
- const oldFolder = path5.join(home, ".callsign");
2091
- const newFolder = path5.join(home, ".heyterm");
2351
+ const oldFolder = path7.join(home, ".callsign");
2352
+ const newFolder = path7.join(home, ".heyterm");
2092
2353
  if (!await isFolder(oldFolder) || await exists(newFolder)) return;
2093
2354
  await rename2(oldFolder, newFolder);
2094
2355
  }
@@ -2107,23 +2368,23 @@ async function exists(target) {
2107
2368
  }
2108
2369
 
2109
2370
  // src/server/setup/user-config.ts
2110
- import { mkdir as mkdir4, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
2111
- import path6 from "node:path";
2371
+ import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
2372
+ import path8 from "node:path";
2112
2373
  var UserConfig = class {
2113
2374
  filePath;
2114
2375
  constructor(dataDirectory2) {
2115
- this.filePath = path6.join(dataDirectory2, "config.json");
2376
+ this.filePath = path8.join(dataDirectory2, "config.json");
2116
2377
  }
2117
2378
  async load() {
2118
2379
  try {
2119
- return JSON.parse(await readFile3(this.filePath, "utf8"));
2380
+ return JSON.parse(await readFile4(this.filePath, "utf8"));
2120
2381
  } catch {
2121
2382
  return {};
2122
2383
  }
2123
2384
  }
2124
2385
  async update(change) {
2125
2386
  const next = { ...await this.load(), ...change };
2126
- await mkdir4(path6.dirname(this.filePath), { recursive: true });
2387
+ await mkdir4(path8.dirname(this.filePath), { recursive: true });
2127
2388
  await writeFile3(this.filePath, `${JSON.stringify(next, null, 2)}
2128
2389
  `, { mode: 384 });
2129
2390
  return next;
@@ -2133,16 +2394,16 @@ var UserConfig = class {
2133
2394
  // src/server/config.ts
2134
2395
  var defaultProjectRoots = ["Documents/Projects", "Projects", "code", "dev", "src", "Developer"];
2135
2396
  function builtWebDirectory() {
2136
- const besideBundle = path7.resolve(import.meta.dirname, "../web");
2137
- return existsSync(path7.join(besideBundle, "index.html")) ? besideBundle : path7.resolve(import.meta.dirname, "../../dist/web");
2397
+ const besideBundle = path9.resolve(import.meta.dirname, "../web");
2398
+ return existsSync(path9.join(besideBundle, "index.html")) ? besideBundle : path9.resolve(import.meta.dirname, "../../dist/web");
2138
2399
  }
2139
2400
  async function loadConfig() {
2140
2401
  const home = os2.homedir();
2141
2402
  if (!process.env.HEYTERM_DATA_DIRECTORY) await DataFolder.moveFromOldName(home).catch(() => void 0);
2142
- const dataDirectory2 = process.env.HEYTERM_DATA_DIRECTORY ?? path7.join(home, ".heyterm");
2403
+ const dataDirectory2 = process.env.HEYTERM_DATA_DIRECTORY ?? path9.join(home, ".heyterm");
2143
2404
  const saved = await new UserConfig(dataDirectory2).load();
2144
- const environmentRoots = process.env.HEYTERM_PROJECT_ROOTS?.split(path7.delimiter).filter(Boolean);
2145
- const projectRoots = environmentRoots ?? saved.projectRoots ?? defaultProjectRoots.map((root) => path7.join(home, root)).filter((root) => existsSync(root));
2405
+ const environmentRoots = process.env.HEYTERM_PROJECT_ROOTS?.split(path9.delimiter).filter(Boolean);
2406
+ const projectRoots = environmentRoots ?? saved.projectRoots ?? defaultProjectRoots.map((root) => path9.join(home, root)).filter((root) => existsSync(root));
2146
2407
  return {
2147
2408
  port: Number(process.env.HEYTERM_PORT ?? 4747),
2148
2409
  dataDirectory: dataDirectory2,
@@ -2154,6 +2415,18 @@ async function loadConfig() {
2154
2415
  };
2155
2416
  }
2156
2417
 
2418
+ // src/shared/page-build.ts
2419
+ var PageBuild = class {
2420
+ static entryFromIndexHtml(html) {
2421
+ return /<script[^>]*type="module"[^>]*src="\/assets\/([^"]+\.js)"/.exec(html)?.[1];
2422
+ }
2423
+ static verdict({ pageEntry, serverEntry, entryOnDisk }) {
2424
+ if (!pageEntry || !serverEntry || pageEntry === serverEntry) return "same";
2425
+ if (entryOnDisk && entryOnDisk !== serverEntry) return "restart_server";
2426
+ return "reload_page";
2427
+ }
2428
+ };
2429
+
2157
2430
  // src/server/hooks/held-hook-requests.ts
2158
2431
  var holdMilliseconds = (permissionHookTimeoutSeconds - 10) * 1e3;
2159
2432
  var HeldHookRequests = class {
@@ -2183,6 +2456,9 @@ var HeldHookRequests = class {
2183
2456
  if (request.sessionName === sessionName && requestId !== exceptRequestId) this.finish(requestId, request, "");
2184
2457
  }
2185
2458
  }
2459
+ renameSession(currentName, newName) {
2460
+ for (const request of this.held.values()) if (request.sessionName === currentName) request.sessionName = newName;
2461
+ }
2186
2462
  finish(requestId, request, body) {
2187
2463
  clearTimeout(request.timer);
2188
2464
  this.held.delete(requestId);
@@ -2191,9 +2467,9 @@ var HeldHookRequests = class {
2191
2467
  };
2192
2468
 
2193
2469
  // src/server/hooks/agent-hook-files.ts
2194
- import { copyFile as copyFile2, mkdir as mkdir5, readFile as readFile4, rename as rename3, rm as rm2, stat as stat2, writeFile as writeFile4 } from "node:fs/promises";
2470
+ import { copyFile as copyFile2, mkdir as mkdir5, readFile as readFile5, rename as rename3, rm as rm2, stat as stat2, writeFile as writeFile4 } from "node:fs/promises";
2195
2471
  import os3 from "node:os";
2196
- import path8 from "node:path";
2472
+ import path10 from "node:path";
2197
2473
 
2198
2474
  // src/server/hooks/global-agent-hooks.ts
2199
2475
  var grokEvents = ["SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop", "StopCancelled", "StopFailure", "Notification", "SessionEnd"];
@@ -2207,16 +2483,18 @@ var GlobalAgentHooks = class _GlobalAgentHooks {
2207
2483
  return { hooks };
2208
2484
  }
2209
2485
  static cursorWithHeyTerm(file, scriptPath) {
2210
- const cleaned = _GlobalAgentHooks.cursorWithoutHeyTerm(file);
2486
+ const cleaned = _GlobalAgentHooks.cursorWithoutHeyTerm(file, scriptPath);
2211
2487
  const hooks = { ...cleaned.hooks ?? {} };
2212
2488
  for (const eventName of cursorEvents) hooks[eventName] = [...hooks[eventName] ?? [], { command: HookCommands.forAgent(scriptPath, eventName, "cursor"), timeout: hookTimeoutSeconds }];
2213
2489
  return { ...cleaned, version: cleaned.version ?? 1, hooks };
2214
2490
  }
2215
- static cursorWithoutHeyTerm(file) {
2491
+ // With a forwarder path, only that copy's hooks go; without one, every copy's.
2492
+ static cursorWithoutHeyTerm(file, scriptPath) {
2216
2493
  if (!file.hooks) return file;
2494
+ const isOurs = (command) => scriptPath ? HookCommands.belongsTo(command, scriptPath) : HookCommands.isHeyTerm(command);
2217
2495
  const remaining = {};
2218
2496
  for (const [eventName, entries] of Object.entries(file.hooks)) {
2219
- const kept = entries.filter((entry) => !HookCommands.isHeyTerm(entry.command ?? ""));
2497
+ const kept = entries.filter((entry) => !isOurs(entry.command ?? ""));
2220
2498
  if (kept.length > 0) remaining[eventName] = kept;
2221
2499
  }
2222
2500
  const { hooks: _removedHooks, ...rest } = file;
@@ -2227,8 +2505,8 @@ var GlobalAgentHooks = class _GlobalAgentHooks {
2227
2505
  // src/server/hooks/agent-hook-files.ts
2228
2506
  function agentHookPaths() {
2229
2507
  return {
2230
- grokPath: process.env.HEYTERM_GROK_HOOKS_PATH ?? path8.join(process.env.GROK_HOME ?? path8.join(os3.homedir(), ".grok"), "hooks", "heyterm.json"),
2231
- cursorPath: process.env.HEYTERM_CURSOR_HOOKS_PATH ?? path8.join(os3.homedir(), ".cursor", "hooks.json")
2508
+ grokPath: process.env.HEYTERM_GROK_HOOKS_PATH ?? path10.join(process.env.GROK_HOME ?? path10.join(os3.homedir(), ".grok"), "hooks", "heyterm.json"),
2509
+ cursorPath: process.env.HEYTERM_CURSOR_HOOKS_PATH ?? path10.join(os3.homedir(), ".cursor", "hooks.json")
2232
2510
  };
2233
2511
  }
2234
2512
  var AgentHookFiles = class {
@@ -2240,38 +2518,71 @@ var AgentHookFiles = class {
2240
2518
  scriptPath;
2241
2519
  backupDirectory;
2242
2520
  paths;
2521
+ isInstalled = false;
2243
2522
  async install() {
2244
- if (await isFolder2(path8.dirname(path8.dirname(this.paths.grokPath)))) {
2245
- await mkdir5(path8.dirname(this.paths.grokPath), { recursive: true });
2246
- await writeAtomically(this.paths.grokPath, GlobalAgentHooks.grokHookFile(this.scriptPath));
2523
+ this.isInstalled = true;
2524
+ await this.installGrok();
2525
+ await this.installCursor();
2526
+ }
2527
+ // While HeyTerm runs, something else may rewrite or delete these files (the agent itself, a second
2528
+ // copy of HeyTerm stopping). Puts HeyTerm's hooks back if they are gone; true when it did.
2529
+ async repairIfRemoved() {
2530
+ if (!this.isInstalled) return false;
2531
+ let repaired = false;
2532
+ if (await this.grokIsInstalled() && !(await readFile5(this.paths.grokPath, "utf8").catch(() => "")).includes(this.marker("Stop", "grok"))) {
2533
+ await this.installGrok();
2534
+ repaired = true;
2247
2535
  }
2248
- if (await isFolder2(path8.dirname(this.paths.cursorPath))) {
2536
+ if (await isFolder2(path10.dirname(this.paths.cursorPath))) {
2249
2537
  const current = await readJson(this.paths.cursorPath);
2250
- if (!current) return;
2251
- await this.backUp(this.paths.cursorPath);
2252
- await writeAtomically(this.paths.cursorPath, GlobalAgentHooks.cursorWithHeyTerm(current, this.scriptPath));
2538
+ if (current && !JSON.stringify(current).includes(this.marker("stop", "cursor"))) {
2539
+ await this.installCursor();
2540
+ repaired = true;
2541
+ }
2253
2542
  }
2543
+ return repaired;
2254
2544
  }
2255
2545
  async remove() {
2546
+ this.isInstalled = false;
2256
2547
  await rm2(this.paths.grokPath, { force: true });
2257
2548
  const current = await readJson(this.paths.cursorPath);
2258
2549
  if (!current || !current.hooks) return;
2259
- const cleaned = GlobalAgentHooks.cursorWithoutHeyTerm(current);
2550
+ const cleaned = GlobalAgentHooks.cursorWithoutHeyTerm(current, this.scriptPath);
2260
2551
  if (JSON.stringify(cleaned) === JSON.stringify(current)) return;
2261
2552
  const holdsNothingElse = Object.keys(cleaned).every((key) => key === "version");
2262
2553
  if (holdsNothingElse) await rm2(this.paths.cursorPath, { force: true });
2263
2554
  else await writeAtomically(this.paths.cursorPath, cleaned);
2264
2555
  }
2556
+ async installGrok() {
2557
+ if (!await this.grokIsInstalled()) return;
2558
+ await mkdir5(path10.dirname(this.paths.grokPath), { recursive: true });
2559
+ await writeAtomically(this.paths.grokPath, GlobalAgentHooks.grokHookFile(this.scriptPath));
2560
+ }
2561
+ async installCursor() {
2562
+ if (!await isFolder2(path10.dirname(this.paths.cursorPath))) return;
2563
+ const current = await readJson(this.paths.cursorPath);
2564
+ if (!current) return;
2565
+ await this.backUp(this.paths.cursorPath);
2566
+ await writeAtomically(this.paths.cursorPath, GlobalAgentHooks.cursorWithHeyTerm(current, this.scriptPath));
2567
+ }
2568
+ // ~/.grok/hooks/heyterm.json: the agent's folder is two levels up.
2569
+ grokIsInstalled() {
2570
+ return isFolder2(path10.dirname(path10.dirname(this.paths.grokPath)));
2571
+ }
2572
+ // One of HeyTerm's hook commands as it reads inside a JSON file, to tell whether its hooks are still there.
2573
+ marker(eventName, agent) {
2574
+ return JSON.stringify(HookCommands.forAgent(this.scriptPath, eventName, agent)).slice(1, -1);
2575
+ }
2265
2576
  async backUp(filePath) {
2266
2577
  await mkdir5(this.backupDirectory, { recursive: true });
2267
- await copyFile2(filePath, path8.join(this.backupDirectory, `cursor-hooks-${(/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-")}.json`)).catch(() => void 0);
2578
+ await copyFile2(filePath, path10.join(this.backupDirectory, `cursor-hooks-${(/* @__PURE__ */ new Date()).toISOString().replaceAll(":", "-")}.json`)).catch(() => void 0);
2268
2579
  }
2269
2580
  };
2270
2581
  async function isFolder2(folder) {
2271
2582
  return stat2(folder).then((info) => info.isDirectory(), () => false);
2272
2583
  }
2273
2584
  async function readJson(filePath) {
2274
- const text2 = await readFile4(filePath, "utf8").catch((error) => error.code === "ENOENT" ? "{}" : void 0);
2585
+ const text2 = await readFile5(filePath, "utf8").catch((error) => error.code === "ENOENT" ? "{}" : void 0);
2275
2586
  if (text2 === void 0) return void 0;
2276
2587
  try {
2277
2588
  return JSON.parse(text2 || "{}");
@@ -2301,7 +2612,7 @@ var HeyTermHttpServer = class {
2301
2612
  this.hub = hub;
2302
2613
  this.terminals = terminals;
2303
2614
  this.tickets = tickets;
2304
- this.serveWeb = existsSync2(webDirectory) ? sirv(webDirectory, { single: true, setHeaders: cachingHeaders }) : void 0;
2615
+ this.serveWeb = existsSync2(webDirectory) ? sirv(webDirectory, { dev: true, single: true, setHeaders: cachingHeaders }) : void 0;
2305
2616
  this.server.on("upgrade", (request, socket, head) => {
2306
2617
  if (!this.guard.isFromThisMac(request) || !this.guard.hasToken(request)) return socket.destroy();
2307
2618
  const url = new URL(request.url ?? "/", "http://127.0.0.1");
@@ -2449,13 +2760,14 @@ function readBody(request, largestBytes) {
2449
2760
  // src/server/http/event-hub.ts
2450
2761
  var snapshotThrottleMilliseconds = 50;
2451
2762
  var EventHub = class {
2452
- constructor(registry, executor, projects, setup, macVoice, assistant) {
2763
+ constructor(registry, executor, projects, setup, macVoice, assistant, pageEntry) {
2453
2764
  this.registry = registry;
2454
2765
  this.executor = executor;
2455
2766
  this.projects = projects;
2456
2767
  this.setup = setup;
2457
2768
  this.macVoice = macVoice;
2458
2769
  this.assistant = assistant;
2770
+ this.pageEntry = pageEntry;
2459
2771
  assistant.on("conversation", (entries) => this.broadcast({ type: "conversation", entries }));
2460
2772
  assistant.on("say", (text2) => this.broadcast({ type: "say", text: text2 }));
2461
2773
  registry.on("changed", () => this.scheduleSnapshot());
@@ -2471,6 +2783,7 @@ var EventHub = class {
2471
2783
  setup;
2472
2784
  macVoice;
2473
2785
  assistant;
2786
+ pageEntry;
2474
2787
  sockets = /* @__PURE__ */ new Set();
2475
2788
  // How many HeyTerm pages are open, so closing the last one can close HeyTerm.
2476
2789
  onPageCountChange = () => void 0;
@@ -2545,10 +2858,22 @@ var EventHub = class {
2545
2858
  return {
2546
2859
  type: "snapshot",
2547
2860
  sessions: this.registry.list(),
2548
- recentlyClosed: this.registry.recentlyClosedSessions().map((session) => ({ name: session.name, projectName: session.projectName })),
2861
+ recentlyClosed: this.registry.recentlyClosedSessions().map((session) => ({
2862
+ name: session.name,
2863
+ projectName: session.projectName,
2864
+ agent: session.agent,
2865
+ projectPath: session.projectPath,
2866
+ ...session.origin ? { origin: session.origin } : {},
2867
+ ...session.agentSessionId ? { agentSessionId: session.agentSessionId } : {},
2868
+ ...session.faceSeed ? { faceSeed: session.faceSeed } : {},
2869
+ ...session.worktree ? { worktree: session.worktree } : {},
2870
+ createdAt: session.createdAt,
2871
+ closedAt: session.stateSince
2872
+ })),
2549
2873
  projects: this.projectNames,
2550
2874
  control: this.executor.controlState(),
2551
- serverTime: Date.now()
2875
+ serverTime: Date.now(),
2876
+ ...this.pageEntry ? { pageEntry: this.pageEntry } : {}
2552
2877
  };
2553
2878
  }
2554
2879
  scheduleSnapshot() {
@@ -2672,7 +2997,7 @@ function parseMessage(text2) {
2672
2997
  // src/server/projects/project-catalog.ts
2673
2998
  import { readdir, stat as stat3 } from "node:fs/promises";
2674
2999
  import os4 from "node:os";
2675
- import path9 from "node:path";
3000
+ import path11 from "node:path";
2676
3001
  var deepestFolderLevel = 3;
2677
3002
  var skippedFolders = /* @__PURE__ */ new Set(["node_modules", "dist", "build", "vendor", "Pods", "DerivedData", "target"]);
2678
3003
  var refreshMilliseconds = 5 * 6e4;
@@ -2699,9 +3024,9 @@ var ProjectCatalog = class {
2699
3024
  const listed = (await this.list()).find((project) => project.name === nameOrPath);
2700
3025
  if (listed) return listed;
2701
3026
  if (!looksLikePath(nameOrPath)) return void 0;
2702
- const folder = path9.resolve(nameOrPath.replace(/^~(?=$|[\\/])/, os4.homedir()));
3027
+ const folder = path11.resolve(nameOrPath.replace(/^~(?=$|[\\/])/, os4.homedir()));
2703
3028
  const isFolder4 = await stat3(folder).then((info) => info.isDirectory(), () => false);
2704
- return isFolder4 ? { name: path9.basename(folder).toLowerCase() || "root", path: folder } : void 0;
3029
+ return isFolder4 ? { name: path11.basename(folder).toLowerCase() || "root", path: folder } : void 0;
2705
3030
  }
2706
3031
  async refresh() {
2707
3032
  const found = [];
@@ -2711,18 +3036,18 @@ var ProjectCatalog = class {
2711
3036
  }
2712
3037
  };
2713
3038
  function looksLikePath(text2) {
2714
- return text2.startsWith("~") || path9.isAbsolute(text2) || text2.includes("/") || text2.includes("\\");
3039
+ return text2.startsWith("~") || path11.isAbsolute(text2) || text2.includes("/") || text2.includes("\\");
2715
3040
  }
2716
3041
  async function collectRepositories(folder, level, found) {
2717
3042
  const entries = await readdir(folder, { withFileTypes: true }).catch(() => []);
2718
3043
  if (entries.some((entry) => entry.name === ".git")) {
2719
- found.push({ name: path9.basename(folder), path: folder });
3044
+ found.push({ name: path11.basename(folder), path: folder });
2720
3045
  return;
2721
3046
  }
2722
3047
  if (level >= deepestFolderLevel) return;
2723
3048
  for (const entry of entries) {
2724
3049
  if (entry.name.startsWith(".") || skippedFolders.has(entry.name)) continue;
2725
- const childPath = path9.join(folder, entry.name);
3050
+ const childPath = path11.join(folder, entry.name);
2726
3051
  const isFolder4 = entry.isDirectory() || entry.isSymbolicLink() && await stat3(childPath).then((info) => info.isDirectory(), () => false);
2727
3052
  if (isFolder4) await collectRepositories(childPath, level + 1, found);
2728
3053
  }
@@ -2731,7 +3056,7 @@ function withUniqueNames(projects) {
2731
3056
  const seen = /* @__PURE__ */ new Set();
2732
3057
  return projects.map((project) => {
2733
3058
  const baseName = project.name.toLowerCase();
2734
- const name = seen.has(baseName) ? `${path9.basename(path9.dirname(project.path)).toLowerCase()}-${baseName}` : baseName;
3059
+ const name = seen.has(baseName) ? `${path11.basename(path11.dirname(project.path)).toLowerCase()}-${baseName}` : baseName;
2735
3060
  seen.add(name);
2736
3061
  return { ...project, name };
2737
3062
  });
@@ -2739,14 +3064,14 @@ function withUniqueNames(projects) {
2739
3064
 
2740
3065
  // src/server/safety/audit-log.ts
2741
3066
  import { appendFile, mkdir as mkdir6 } from "node:fs/promises";
2742
- import path10 from "node:path";
3067
+ import path12 from "node:path";
2743
3068
  var AuditLog = class {
2744
3069
  filePath;
2745
3070
  constructor(dataDirectory2) {
2746
- this.filePath = path10.join(dataDirectory2, "audit.jsonl");
3071
+ this.filePath = path12.join(dataDirectory2, "audit.jsonl");
2747
3072
  }
2748
3073
  async record(entry) {
2749
- await mkdir6(path10.dirname(this.filePath), { recursive: true });
3074
+ await mkdir6(path12.dirname(this.filePath), { recursive: true });
2750
3075
  await appendFile(this.filePath, `${JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString(), ...entry })}
2751
3076
  `, { mode: 384 });
2752
3077
  }
@@ -2754,9 +3079,9 @@ var AuditLog = class {
2754
3079
 
2755
3080
  // src/server/sessions/session-launcher.ts
2756
3081
  import { randomUUID } from "node:crypto";
2757
- import { execFile } from "node:child_process";
3082
+ import { execFile as execFile2 } from "node:child_process";
2758
3083
  import { access } from "node:fs/promises";
2759
- import { promisify } from "node:util";
3084
+ import { promisify as promisify2 } from "node:util";
2760
3085
 
2761
3086
  // src/server/sessions/screen-reader.ts
2762
3087
  var trustOptionText = /Yes, I trust this folder|\bTrust (?:all )?and continue\b/;
@@ -2793,6 +3118,27 @@ var ScreenReader = class _ScreenReader {
2793
3118
  }
2794
3119
  };
2795
3120
 
3121
+ // src/server/sessions/worktree-plan.ts
3122
+ import path13 from "node:path";
3123
+ var WorktreePlan = class {
3124
+ static needsOwnWorktree(projectPath, openSessions) {
3125
+ return openSessions.some((session) => session.state !== "stopped" && foldersOverlap(session.projectPath, projectPath));
3126
+ }
3127
+ static branchName(sessionName, existingBranches) {
3128
+ const base = `heyterm/${sessionName}`;
3129
+ if (!existingBranches.includes(base)) return base;
3130
+ for (let suffix = 2; ; suffix += 1) {
3131
+ const candidate = `${base}-${suffix}`;
3132
+ if (!existingBranches.includes(candidate)) return candidate;
3133
+ }
3134
+ }
3135
+ };
3136
+ function foldersOverlap(first, second) {
3137
+ const a = path13.resolve(first);
3138
+ const b = path13.resolve(second);
3139
+ return a === b || a.startsWith(b + path13.sep) || b.startsWith(a + path13.sep);
3140
+ }
3141
+
2796
3142
  // src/server/terminals/launch-command.ts
2797
3143
  var LaunchCommand = class {
2798
3144
  static forPlatform(program, args, platform = process.platform) {
@@ -2811,28 +3157,33 @@ function windowsQuote(value) {
2811
3157
  }
2812
3158
 
2813
3159
  // src/server/sessions/session-launcher.ts
2814
- var execFileAsync = promisify(execFile);
3160
+ var execFileAsync2 = promisify2(execFile2);
2815
3161
  var promptConfirmationMilliseconds = 2500;
2816
3162
  var pasteSettleMilliseconds = 150;
2817
3163
  var AgentNotInstalledError = class extends Error {
2818
3164
  };
2819
3165
  var SessionLauncher = class {
2820
- constructor(host, registry, paths) {
3166
+ constructor(host, registry, paths, worktrees) {
2821
3167
  this.host = host;
2822
3168
  this.registry = registry;
2823
3169
  this.paths = paths;
3170
+ this.worktrees = worktrees;
2824
3171
  host.on("exit", (name) => this.registry.remove(name));
2825
3172
  }
2826
3173
  host;
2827
3174
  registry;
2828
3175
  paths;
3176
+ worktrees;
2829
3177
  async start(projectPath, projectName, task, agent = "claude") {
2830
3178
  await this.ensureInstalled(agent);
2831
3179
  const name = this.uniqueName(projectName);
3180
+ const sharesAFolder = WorktreePlan.needsOwnWorktree(projectPath, this.registry.list());
3181
+ const made = sharesAFolder && this.worktrees ? await this.worktrees.create(projectPath, name) : void 0;
2832
3182
  const now = Date.now();
2833
3183
  const session = {
2834
3184
  name,
2835
- projectPath,
3185
+ projectPath: made?.path ?? projectPath,
3186
+ ...made ? { worktree: { repoPath: made.repoPath, worktreePath: made.worktreePath, branch: made.branch, baseCommit: made.baseCommit } } : {},
2836
3187
  projectName,
2837
3188
  agent,
2838
3189
  state: "starting",
@@ -2862,6 +3213,16 @@ var SessionLauncher = class {
2862
3213
  this.launch(session, adapter.resume(point, { ...this.launchContext(session.name), agentSessionId: launchId }));
2863
3214
  return session;
2864
3215
  }
3216
+ // When a closed session leaves the list, its worktree goes too, unless that would lose work.
3217
+ removeWorktreeIfUnused(worktree) {
3218
+ return this.worktrees ? this.worktrees.removeIfUnused(worktree) : Promise.resolve("missing");
3219
+ }
3220
+ // Returns why the session could not take the name, or undefined once it has.
3221
+ rename(currentName, newName) {
3222
+ const problem = this.registry.rename(currentName, newName);
3223
+ if (!problem) this.host.renameSession(currentName, newName);
3224
+ return problem;
3225
+ }
2865
3226
  async close(name) {
2866
3227
  this.host.killSession(name);
2867
3228
  this.registry.remove(name);
@@ -2897,19 +3258,16 @@ var SessionLauncher = class {
2897
3258
  async hasUncommittedChanges(name) {
2898
3259
  const session = this.registry.get(name);
2899
3260
  if (!session) return false;
2900
- const status = await execFileAsync("git", ["-C", session.projectPath, "status", "--porcelain"], { timeout: 5e3 }).then(
3261
+ const status = await execFileAsync2("git", ["-C", session.projectPath, "status", "--porcelain"], { timeout: 5e3 }).then(
2901
3262
  ({ stdout }) => stdout.trim(),
2902
3263
  () => ""
2903
3264
  );
2904
3265
  return status.length > 0;
2905
3266
  }
2906
- // At start nothing is running yet: sessions saved last time are offered under "Recently closed",
2907
- // and outside sessions report again on their next event.
3267
+ // HeyTerm's own sessions ended with it. Sessions in the user's terminal stay, names and all:
3268
+ // ClaudeSessionWatcher closes the ones whose Claude has gone.
2908
3269
  restoreAfterStart() {
2909
- for (const session of this.registry.list()) {
2910
- if (session.origin === "external") this.registry.forget(session.name);
2911
- else this.registry.remove(session.name);
2912
- }
3270
+ for (const session of this.registry.list()) if (session.origin !== "external") this.registry.remove(session.name);
2913
3271
  }
2914
3272
  // HeyTerm is stopping: every session ends and waits under "Recently closed" for next time.
2915
3273
  shutdown() {
@@ -2959,16 +3317,16 @@ var SessionLauncher = class {
2959
3317
  }
2960
3318
  uniqueName(projectName) {
2961
3319
  const base = projectName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "session";
2962
- if (!this.registry.get(base)) return base;
3320
+ if (!this.registry.isNameInUse(base)) return base;
2963
3321
  for (let suffix = 2; ; suffix += 1) {
2964
3322
  const candidate = `${base}-${suffix}`;
2965
- if (!this.registry.get(candidate)) return candidate;
3323
+ if (!this.registry.isNameInUse(candidate)) return candidate;
2966
3324
  }
2967
3325
  }
2968
3326
  };
2969
3327
  async function isOnPath(program) {
2970
3328
  const finder = process.platform === "win32" ? "where" : "which";
2971
- return execFileAsync(finder, [program], { timeout: 5e3 }).then(
3329
+ return execFileAsync2(finder, [program], { timeout: 5e3 }).then(
2972
3330
  () => true,
2973
3331
  () => false
2974
3332
  );
@@ -2988,26 +3346,84 @@ function delay(milliseconds) {
2988
3346
  // src/server/sessions/session-registry.ts
2989
3347
  import { randomUUID as randomUUID2 } from "node:crypto";
2990
3348
  import { EventEmitter as EventEmitter4 } from "node:events";
2991
- import path13 from "node:path";
3349
+ import path16 from "node:path";
2992
3350
 
2993
3351
  // src/server/sessions/agent-session-match.ts
2994
- import path11 from "node:path";
3352
+ import path14 from "node:path";
2995
3353
  var AgentSessionMatch = class {
2996
3354
  static find(sessions, agent, agentSessionId, workingDirectory) {
2997
3355
  const ours = sessions.filter((session) => session.origin !== "external" && session.agent === agent);
2998
3356
  const known = ours.find((session) => session.agentSessionId === agentSessionId);
2999
3357
  if (known) return { session: known, claimsId: false };
3000
- const folder = path11.resolve(workingDirectory || "/");
3001
- const unclaimed = ours.filter((session) => !session.agentSessionId && path11.resolve(session.projectPath) === folder).sort((left, right) => right.createdAt - left.createdAt);
3358
+ const folder = path14.resolve(workingDirectory || "/");
3359
+ const unclaimed = ours.filter((session) => !session.agentSessionId && path14.resolve(session.projectPath) === folder).sort((left, right) => right.createdAt - left.createdAt);
3002
3360
  return unclaimed[0] ? { session: unclaimed[0], claimsId: true } : void 0;
3003
3361
  }
3004
3362
  };
3005
3363
 
3364
+ // src/server/sessions/claude-session-status.ts
3365
+ var statesInsideATurn = ["working", "needs_permission", "asked"];
3366
+ var statusesWithoutATurn = ["idle", "shell"];
3367
+ var ClaudeSessionStatus = class _ClaudeSessionStatus {
3368
+ // The Stop hook can land just after the record turns idle; a real end of turn wins.
3369
+ static settleMilliseconds = 3e3;
3370
+ // A session that only just appeared may not have written its record yet.
3371
+ static endGraceMilliseconds = 3e4;
3372
+ static verdict(session, record2, processIsRunning, now) {
3373
+ if (session.agent !== "claude" || !session.agentSessionId || session.state === "stopped") return void 0;
3374
+ if (!record2 || !processIsRunning) {
3375
+ if (session.origin !== "external") return void 0;
3376
+ return now - session.stateSince >= _ClaudeSessionStatus.endGraceMilliseconds ? "ended" : void 0;
3377
+ }
3378
+ if (!statesInsideATurn.includes(session.state)) return void 0;
3379
+ if (!record2.status || !statusesWithoutATurn.includes(record2.status)) return void 0;
3380
+ const statusChangedAt = record2.statusUpdatedAt ?? 0;
3381
+ if (statusChangedAt <= session.stateSince) return void 0;
3382
+ return now - statusChangedAt >= _ClaudeSessionStatus.settleMilliseconds ? "turn_stopped" : void 0;
3383
+ }
3384
+ // A folder with no records means no Claude is running. Record files of which none can be read
3385
+ // mean Claude Code changed how it writes them, which says nothing about the sessions.
3386
+ static recordsCanBeTrusted(recordFileCount, readableRecordCount) {
3387
+ return recordFileCount === 0 || readableRecordCount > 0;
3388
+ }
3389
+ static parseRecord(text2) {
3390
+ let parsed;
3391
+ try {
3392
+ parsed = JSON.parse(text2);
3393
+ } catch {
3394
+ return void 0;
3395
+ }
3396
+ if (typeof parsed !== "object" || parsed === null) return void 0;
3397
+ const fields = parsed;
3398
+ if (typeof fields.pid !== "number" || typeof fields.sessionId !== "string" || !fields.sessionId) return void 0;
3399
+ return {
3400
+ pid: fields.pid,
3401
+ sessionId: fields.sessionId,
3402
+ ...typeof fields.status === "string" ? { status: fields.status } : {},
3403
+ ...typeof fields.statusUpdatedAt === "number" ? { statusUpdatedAt: fields.statusUpdatedAt } : {}
3404
+ };
3405
+ }
3406
+ };
3407
+
3408
+ // src/server/sessions/quiet-rules.ts
3409
+ var quietAfterMilliseconds = 5 * 6e4;
3410
+ var longCommandAfterMilliseconds = 10 * 6e4;
3411
+ var QuietRules = class {
3412
+ static verdict(session, now) {
3413
+ if (session.state !== "working" || session.quiet) return void 0;
3414
+ if (session.runningCommand) {
3415
+ return now - session.runningCommand.since >= longCommandAfterMilliseconds ? { kind: "long_command", since: session.runningCommand.since } : void 0;
3416
+ }
3417
+ const lastHeardAt = session.lastActivityAt ?? session.stateSince;
3418
+ return now - lastHeardAt >= quietAfterMilliseconds ? { kind: "quiet", since: lastHeardAt } : void 0;
3419
+ }
3420
+ };
3421
+
3006
3422
  // src/server/sessions/external-session-naming.ts
3007
- import path12 from "node:path";
3423
+ import path15 from "node:path";
3008
3424
  var ExternalSessionNaming = class {
3009
3425
  static nameFor(facts, takenNames) {
3010
- const base = slug(facts.sessionTitle ?? "") || slug(path12.basename(facts.workingDirectory)) || "session";
3426
+ const base = slug(facts.sessionTitle ?? "") || slug(path15.basename(facts.workingDirectory)) || "session";
3011
3427
  if (!takenNames.includes(base)) return base;
3012
3428
  for (let suffix = 2; ; suffix += 1) {
3013
3429
  const candidate = `${base}-${suffix}`;
@@ -3125,12 +3541,20 @@ var SessionRegistry = class extends EventEmitter4 {
3125
3541
  sessions = /* @__PURE__ */ new Map();
3126
3542
  recentlyClosed = [];
3127
3543
  promptWaiters = /* @__PURE__ */ new Map();
3544
+ turnChangeTracker;
3545
+ // Launch name to current name, for renamed sessions HeyTerm started: their hooks keep the launch name.
3546
+ launchNames = /* @__PURE__ */ new Map();
3128
3547
  dayKey = localDayKey(Date.now());
3129
3548
  changeTimer;
3130
3549
  saveTimer;
3131
3550
  restore(sessions, recentlyClosed) {
3132
3551
  for (const session of sessions) this.sessions.set(session.name, session);
3133
- this.recentlyClosed = recentlyClosed;
3552
+ const takenNames = new Set(this.sessions.keys());
3553
+ this.recentlyClosed = recentlyClosed.map((closed) => {
3554
+ const name = takenNames.has(closed.name) ? freeNameLike(closed.name, takenNames) : closed.name;
3555
+ takenNames.add(name);
3556
+ return name === closed.name ? closed : { ...closed, name };
3557
+ });
3134
3558
  }
3135
3559
  list() {
3136
3560
  return [...this.sessions.values()].sort((left, right) => left.createdAt - right.createdAt);
@@ -3150,10 +3574,23 @@ var SessionRegistry = class extends EventEmitter4 {
3150
3574
  if (!session) return;
3151
3575
  this.sessions.delete(name);
3152
3576
  this.heldRequests.letGoAllFor(name);
3153
- const closed = { ...session, state: "stopped", pending: void 0 };
3577
+ for (const [launchName, renamedTo] of this.launchNames) if (renamedTo === name) this.launchNames.delete(launchName);
3578
+ const closed = { ...session, state: "stopped", stateSince: Date.now(), pending: void 0 };
3154
3579
  this.recentlyClosed = [closed, ...this.recentlyClosed].slice(0, recentlyClosedLimit);
3155
3580
  this.announceChange();
3156
3581
  }
3582
+ // Takes a closed session off the list. Its conversation stays with the agent, which can still resume it.
3583
+ forgetClosed(name) {
3584
+ const before = this.recentlyClosed.length;
3585
+ this.recentlyClosed = this.recentlyClosed.filter((closed) => closed.name !== name);
3586
+ if (this.recentlyClosed.length === before) return false;
3587
+ this.announceChange();
3588
+ return true;
3589
+ }
3590
+ // Without Claude Code's records nothing can say whether these are still running.
3591
+ forgetExternalSessions() {
3592
+ for (const session of this.list()) if (session.origin === "external") this.forget(session.name);
3593
+ }
3157
3594
  // A session that left while HeyTerm was away; it is not worth a "recently closed" row.
3158
3595
  forget(name) {
3159
3596
  if (!this.sessions.delete(name)) return;
@@ -3167,19 +3604,61 @@ var SessionRegistry = class extends EventEmitter4 {
3167
3604
  this.set(updated);
3168
3605
  return updated;
3169
3606
  }
3607
+ // Lets git say what a task changed, which edit hooks miss when a shell command does the editing.
3608
+ useTurnChangeTracker(tracker) {
3609
+ this.turnChangeTracker = tracker;
3610
+ }
3170
3611
  // Returns what the hook script should print: a decision for a held request, otherwise nothing.
3171
3612
  async applyHookEvent(event) {
3172
3613
  this.resetWaitsOnNewDay(event.receivedAt);
3173
- const before = this.sessions.get(event.sessionName);
3614
+ const sessionName = this.currentName(event.sessionName);
3615
+ const before = this.sessions.get(sessionName);
3174
3616
  if (!before) return "";
3617
+ if (event.name === "UserPromptSubmit") this.turnChangeTracker?.taskStarted(before);
3618
+ const changedByTask = event.name === "Stop" && this.turnChangeTracker ? await this.turnChangeTracker.filesChangedByTask(before).catch(() => []) : [];
3175
3619
  const requestId = event.name === "PermissionRequest" ? randomUUID2() : void 0;
3176
- const after = SessionLifecycle.apply(before, event, { requestId });
3620
+ const lifecycleResult = SessionLifecycle.apply(this.sessions.get(sessionName) ?? before, event, { requestId });
3621
+ const after = changedByTask.length > 0 ? { ...lifecycleResult, filesChangedInLastTask: [.../* @__PURE__ */ new Set([...lifecycleResult.filesChangedInLastTask ?? [], ...changedByTask])] } : lifecycleResult;
3177
3622
  this.set(after);
3178
3623
  if (before.pending && before.pending.requestId !== after.pending?.requestId) {
3179
- this.heldRequests.letGoAllFor(event.sessionName, after.pending?.requestId);
3624
+ this.heldRequests.letGoAllFor(sessionName, after.pending?.requestId);
3625
+ }
3626
+ if (event.name === "UserPromptSubmit") this.resolvePromptWaiters(sessionName);
3627
+ return requestId ? this.heldRequests.hold(requestId, sessionName) : "";
3628
+ }
3629
+ // Renames an open or recently closed session; returns why it could not, or undefined.
3630
+ rename(currentName, newName) {
3631
+ const live = this.sessions.get(currentName);
3632
+ const closedIndex = this.recentlyClosed.findIndex((closed) => closed.name === currentName);
3633
+ if (!live && closedIndex === -1) return `There is no session called ${currentName}.`;
3634
+ const ownLaunchNames = [...this.launchNames].filter(([, renamedTo]) => renamedTo === currentName).map(([launchName]) => launchName);
3635
+ const otherLaunchNames = [...this.launchNames.keys()].filter((launchName) => !ownLaunchNames.includes(launchName));
3636
+ const takenNames = [...this.sessions.keys(), ...this.recentlyClosed.map((closed) => closed.name), ...otherLaunchNames];
3637
+ const problem = SessionNames.renameProblem(newName, currentName, takenNames);
3638
+ if (problem) return problem;
3639
+ if (live) {
3640
+ this.sessions.delete(currentName);
3641
+ this.sessions.set(newName, { ...live, name: newName, faceSeed: live.faceSeed ?? currentName });
3642
+ this.launchNames.set(currentName, newName);
3643
+ for (const [launchName, renamedTo] of this.launchNames) if (renamedTo === currentName) this.launchNames.set(launchName, newName);
3644
+ this.heldRequests.renameSession(currentName, newName);
3645
+ const waiters = this.promptWaiters.get(currentName);
3646
+ if (waiters) {
3647
+ this.promptWaiters.delete(currentName);
3648
+ this.promptWaiters.set(newName, waiters);
3649
+ }
3650
+ } else {
3651
+ this.recentlyClosed = this.recentlyClosed.map((closed, index) => index === closedIndex ? { ...closed, name: newName, faceSeed: closed.faceSeed ?? currentName } : closed);
3180
3652
  }
3181
- if (event.name === "UserPromptSubmit") this.resolvePromptWaiters(event.sessionName);
3182
- return requestId ? this.heldRequests.hold(requestId, event.sessionName) : "";
3653
+ this.announceChange();
3654
+ return void 0;
3655
+ }
3656
+ // Taken by an open or listed closed session, or kept for a renamed one whose hooks still use it.
3657
+ isNameInUse(name) {
3658
+ return this.sessions.has(name) || this.launchNames.has(name) || this.recentlyClosed.some((closed) => closed.name === name);
3659
+ }
3660
+ currentName(reportedName) {
3661
+ return this.sessions.has(reportedName) ? reportedName : this.launchNames.get(reportedName) ?? reportedName;
3183
3662
  }
3184
3663
  // An event from Grok Build or Cursor, which run HeyTerm's hooks from a global file in every session.
3185
3664
  // Only the sessions HeyTerm started move; the reply is what the hook prints.
@@ -3193,6 +3672,26 @@ var SessionRegistry = class extends EventEmitter4 {
3193
3672
  const answer = await this.applyHookEvent({ name: event.name, sessionName: found.session.name, payload: event.payload, receivedAt });
3194
3673
  return answer || defaultReply;
3195
3674
  }
3675
+ // Marks working sessions that have said nothing for long (quiet-rules.ts); each is told once.
3676
+ noticeQuietSessions(now) {
3677
+ for (const session of this.list()) {
3678
+ const quiet = QuietRules.verdict(session, now);
3679
+ if (quiet) this.update(session.name, (current) => ({ ...current, quiet }));
3680
+ }
3681
+ }
3682
+ // Brings Claude sessions in line with Claude Code's own records: a stopped turn returns to idle,
3683
+ // and a session from the user's terminal whose Claude has gone is closed.
3684
+ reconcileClaudeSessions(records, processIsRunning, now) {
3685
+ for (const session of this.list()) {
3686
+ const record2 = session.agentSessionId ? records.get(session.agentSessionId) : void 0;
3687
+ const verdict = ClaudeSessionStatus.verdict(session, record2, record2 ? processIsRunning(record2.pid) : false, now);
3688
+ if (verdict === "turn_stopped") {
3689
+ this.heldRequests.letGoAllFor(session.name);
3690
+ this.update(session.name, (current) => SessionLifecycle.applyTurnStopped(current, now));
3691
+ }
3692
+ if (verdict === "ended") this.remove(session.name);
3693
+ }
3694
+ }
3196
3695
  // An event from a session the user started in their own terminal, known by its Claude session id.
3197
3696
  async applyExternalHookEvent(name, payload, receivedAt) {
3198
3697
  const agentSessionId = typeof payload.session_id === "string" ? payload.session_id : "";
@@ -3210,12 +3709,13 @@ var SessionRegistry = class extends EventEmitter4 {
3210
3709
  newExternalSession(agentSessionId, payload, now) {
3211
3710
  const workingDirectory = typeof payload.cwd === "string" ? payload.cwd : "";
3212
3711
  const sessionTitle = typeof payload.session_title === "string" ? payload.session_title : void 0;
3213
- const name = ExternalSessionNaming.nameFor({ sessionTitle, workingDirectory }, [...this.sessions.keys()]);
3712
+ const takenNames = [...this.sessions.keys(), ...this.launchNames.keys(), ...this.recentlyClosed.map((closed) => closed.name)];
3713
+ const name = ExternalSessionNaming.nameFor({ sessionTitle, workingDirectory }, takenNames);
3214
3714
  return {
3215
3715
  name,
3216
3716
  origin: "external",
3217
3717
  projectPath: workingDirectory,
3218
- projectName: path13.basename(workingDirectory) || name,
3718
+ projectName: path16.basename(workingDirectory) || name,
3219
3719
  agent: "claude",
3220
3720
  state: "idle",
3221
3721
  stateSince: now,
@@ -3279,11 +3779,17 @@ var SessionRegistry = class extends EventEmitter4 {
3279
3779
  function localDayKey(time) {
3280
3780
  return new Date(time).toDateString();
3281
3781
  }
3782
+ function freeNameLike(name, takenNames) {
3783
+ for (let suffix = 2; ; suffix += 1) {
3784
+ const candidate = `${name}-${suffix}`;
3785
+ if (!takenNames.has(candidate)) return candidate;
3786
+ }
3787
+ }
3282
3788
 
3283
3789
  // src/server/sessions/state-store.ts
3284
3790
  import { randomUUID as randomUUID3 } from "node:crypto";
3285
- import { mkdir as mkdir7, readFile as readFile5, rename as rename4, writeFile as writeFile5 } from "node:fs/promises";
3286
- import path14 from "node:path";
3791
+ import { mkdir as mkdir7, readFile as readFile6, rename as rename4, writeFile as writeFile5 } from "node:fs/promises";
3792
+ import path17 from "node:path";
3287
3793
  function upgraded(saved) {
3288
3794
  const { claudeSessionId, ...session } = saved;
3289
3795
  return { ...session, agent: session.agent ?? "claude", agentSessionId: session.agentSessionId ?? claudeSessionId };
@@ -3291,10 +3797,10 @@ function upgraded(saved) {
3291
3797
  var StateStore = class {
3292
3798
  filePath;
3293
3799
  constructor(dataDirectory2) {
3294
- this.filePath = path14.join(dataDirectory2, "state.json");
3800
+ this.filePath = path17.join(dataDirectory2, "state.json");
3295
3801
  }
3296
3802
  async load() {
3297
- const text2 = await readFile5(this.filePath, "utf8").catch(() => "");
3803
+ const text2 = await readFile6(this.filePath, "utf8").catch(() => "");
3298
3804
  if (!text2) return { sessions: [], recentlyClosed: [] };
3299
3805
  try {
3300
3806
  const parsed = JSON.parse(text2);
@@ -3312,7 +3818,7 @@ var StateStore = class {
3312
3818
  }
3313
3819
  saving = Promise.resolve();
3314
3820
  async write(state) {
3315
- await mkdir7(path14.dirname(this.filePath), { recursive: true });
3821
+ await mkdir7(path17.dirname(this.filePath), { recursive: true });
3316
3822
  const temporaryPath = `${this.filePath}.${process.pid}.${randomUUID3()}.tmp`;
3317
3823
  await writeFile5(temporaryPath, JSON.stringify(state), { mode: 384 });
3318
3824
  await rename4(temporaryPath, this.filePath);
@@ -3353,13 +3859,185 @@ var TrustDialogWatcher = class {
3353
3859
  }
3354
3860
  };
3355
3861
 
3862
+ // src/server/sessions/claude-session-watcher.ts
3863
+ import { readdir as readdir2, readFile as readFile7 } from "node:fs/promises";
3864
+ import os5 from "node:os";
3865
+ import path18 from "node:path";
3866
+ var pollMilliseconds2 = 2e3;
3867
+ function claudeSessionsDirectory() {
3868
+ return process.env.HEYTERM_CLAUDE_SESSIONS_DIRECTORY ?? path18.join(process.env.CLAUDE_CONFIG_DIR ?? path18.join(os5.homedir(), ".claude"), "sessions");
3869
+ }
3870
+ var ClaudeSessionWatcher = class _ClaudeSessionWatcher {
3871
+ constructor(registry) {
3872
+ this.registry = registry;
3873
+ }
3874
+ registry;
3875
+ timer;
3876
+ polling = false;
3877
+ hasPolled = false;
3878
+ start() {
3879
+ this.timer = setInterval(() => void this.poll(), pollMilliseconds2);
3880
+ }
3881
+ stop() {
3882
+ clearInterval(this.timer);
3883
+ }
3884
+ async poll() {
3885
+ if (this.polling) return;
3886
+ this.polling = true;
3887
+ try {
3888
+ const records = await _ClaudeSessionWatcher.readRecords(claudeSessionsDirectory());
3889
+ if (records) this.registry.reconcileClaudeSessions(records, _ClaudeSessionWatcher.processIsRunning, Date.now());
3890
+ else if (!this.hasPolled) this.registry.forgetExternalSessions();
3891
+ this.hasPolled = true;
3892
+ } finally {
3893
+ this.polling = false;
3894
+ }
3895
+ }
3896
+ static async readRecords(directory) {
3897
+ const names = await readdir2(directory).catch(() => void 0);
3898
+ if (!names) return void 0;
3899
+ const records = /* @__PURE__ */ new Map();
3900
+ const recordFileNames = names.filter((name) => name.endsWith(".json"));
3901
+ for (const name of recordFileNames) {
3902
+ const text2 = await readFile7(path18.join(directory, name), "utf8").catch(() => "");
3903
+ const record2 = ClaudeSessionStatus.parseRecord(text2);
3904
+ if (record2) records.set(record2.sessionId, record2);
3905
+ }
3906
+ return ClaudeSessionStatus.recordsCanBeTrusted(recordFileNames.length, records.size) ? records : void 0;
3907
+ }
3908
+ // Signal 0 checks a process exists without touching it; EPERM means it exists under another user.
3909
+ static processIsRunning(pid) {
3910
+ try {
3911
+ process.kill(pid, 0);
3912
+ return true;
3913
+ } catch (error) {
3914
+ return error.code === "EPERM";
3915
+ }
3916
+ }
3917
+ };
3918
+
3919
+ // src/server/sessions/turn-change-tracker.ts
3920
+ import { execFile as execFile3 } from "node:child_process";
3921
+
3922
+ // src/server/sessions/turn-changes.ts
3923
+ var TurnChanges = class {
3924
+ static between(before, after, committedDuringTask) {
3925
+ const changed = /* @__PURE__ */ new Set();
3926
+ for (const file of before.changedFiles.keys()) if (!after.changedFiles.has(file)) changed.add(file);
3927
+ for (const [file, hash] of after.changedFiles) if (before.changedFiles.get(file) !== hash) changed.add(file);
3928
+ for (const file of committedDuringTask) changed.add(file);
3929
+ return [...changed];
3930
+ }
3931
+ };
3932
+
3933
+ // src/server/sessions/turn-change-tracker.ts
3934
+ var gitTimeoutMilliseconds = 2e3;
3935
+ var largestOutputBytes = 8 * 1024 * 1024;
3936
+ var TurnChangeTracker = class _TurnChangeTracker {
3937
+ snapshotsAtTaskStart = /* @__PURE__ */ new Map();
3938
+ taskStarted(session) {
3939
+ this.snapshotsAtTaskStart.set(_TurnChangeTracker.key(session), _TurnChangeTracker.take(session.projectPath));
3940
+ }
3941
+ async filesChangedByTask(session) {
3942
+ const key = _TurnChangeTracker.key(session);
3943
+ const before = await this.snapshotsAtTaskStart.get(key);
3944
+ this.snapshotsAtTaskStart.delete(key);
3945
+ if (!before) return [];
3946
+ const after = await _TurnChangeTracker.take(session.projectPath);
3947
+ if (!after) return [];
3948
+ const committed = before.head !== after.head ? (await git(session.projectPath, ["diff", "--name-only", "--relative", before.head, after.head]) ?? "").split("\n").filter(Boolean) : [];
3949
+ return TurnChanges.between(before, after, committed);
3950
+ }
3951
+ // Stays the same when the session is renamed.
3952
+ static key(session) {
3953
+ return `${session.projectPath}|${session.createdAt}`;
3954
+ }
3955
+ static async take(folder) {
3956
+ const prefix = await git(folder, ["rev-parse", "--show-prefix"]);
3957
+ if (prefix === void 0) return void 0;
3958
+ const head = (await git(folder, ["rev-parse", "HEAD"]) ?? "").trim() || "no commits yet";
3959
+ const status = await git(folder, ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", "."]);
3960
+ if (status === void 0) return void 0;
3961
+ const entries = parseStatus(status, prefix.trim());
3962
+ const present = entries.filter((entry) => !entry.deleted).map((entry) => entry.path);
3963
+ const hashes = present.length > 0 ? (await git(folder, ["hash-object", "--stdin-paths"], present.join("\n")) ?? "").split("\n") : [];
3964
+ const changedFiles = /* @__PURE__ */ new Map();
3965
+ for (const entry of entries) changedFiles.set(entry.path, entry.deleted ? "deleted" : hashes[present.indexOf(entry.path)] ?? "unknown");
3966
+ return { head, changedFiles };
3967
+ }
3968
+ };
3969
+ function parseStatus(output, prefix) {
3970
+ const records = output.split("\0");
3971
+ const entries = [];
3972
+ for (let index = 0; index < records.length; index += 1) {
3973
+ const record2 = records[index];
3974
+ if (record2.length < 4) continue;
3975
+ const code = record2.slice(0, 2);
3976
+ const fullPath = record2.slice(3);
3977
+ if (code.includes("R") || code.includes("C")) index += 1;
3978
+ if (!fullPath.startsWith(prefix)) continue;
3979
+ entries.push({ path: fullPath.slice(prefix.length), deleted: code.includes("D") });
3980
+ }
3981
+ return entries;
3982
+ }
3983
+ function git(folder, args, input) {
3984
+ return new Promise((resolve) => {
3985
+ const child = execFile3("git", ["-C", folder, ...args], { timeout: gitTimeoutMilliseconds, maxBuffer: largestOutputBytes }, (error, stdout) => resolve(error ? void 0 : stdout));
3986
+ if (input !== void 0) child.stdin?.end(input);
3987
+ });
3988
+ }
3989
+
3990
+ // src/server/sessions/session-worktrees.ts
3991
+ import { execFile as execFile4 } from "node:child_process";
3992
+ import { existsSync as existsSync3 } from "node:fs";
3993
+ import path19 from "node:path";
3994
+ var SessionWorktrees = class {
3995
+ constructor(dataDirectory2) {
3996
+ this.dataDirectory = dataDirectory2;
3997
+ }
3998
+ dataDirectory;
3999
+ // A worktree for the session, with the folder it should work in, or undefined where git cannot make one.
4000
+ async create(projectPath, sessionName) {
4001
+ const repoPath = (await git2(projectPath, ["rev-parse", "--show-toplevel"]))?.trim();
4002
+ const baseCommit = (await git2(projectPath, ["rev-parse", "HEAD"]))?.trim();
4003
+ const prefix = (await git2(projectPath, ["rev-parse", "--show-prefix"]))?.trim() ?? "";
4004
+ if (!repoPath || !baseCommit) return void 0;
4005
+ const existingBranches = (await git2(repoPath, ["for-each-ref", "--format=%(refname:short)", "refs/heads/heyterm"]) ?? "").split("\n").filter(Boolean);
4006
+ const branch = WorktreePlan.branchName(sessionName, existingBranches);
4007
+ const worktreePath = freeFolder(path19.join(this.dataDirectory, "worktrees", `${path19.basename(repoPath)}-${sessionName}`));
4008
+ if (await git2(repoPath, ["worktree", "add", "-q", "-b", branch, worktreePath, baseCommit]) === void 0) return void 0;
4009
+ const sessionPath = prefix ? path19.join(worktreePath, prefix) : worktreePath;
4010
+ return { repoPath, worktreePath, branch, baseCommit, path: sessionPath };
4011
+ }
4012
+ // Never deletes work: a worktree with uncommitted changes or commits of its own stays.
4013
+ async removeIfUnused(worktree) {
4014
+ if (!existsSync3(worktree.worktreePath)) {
4015
+ await git2(worktree.repoPath, ["worktree", "prune"]);
4016
+ return "missing";
4017
+ }
4018
+ if ((await git2(worktree.worktreePath, ["status", "--porcelain"]) ?? "x").trim()) return "kept_uncommitted";
4019
+ const ownCommits = Number((await git2(worktree.worktreePath, ["rev-list", "--count", `${worktree.baseCommit}..HEAD`]) ?? "1").trim());
4020
+ if (ownCommits > 0) return "kept_commits";
4021
+ await git2(worktree.repoPath, ["worktree", "remove", worktree.worktreePath]);
4022
+ await git2(worktree.repoPath, ["branch", "-D", worktree.branch]);
4023
+ return "removed";
4024
+ }
4025
+ };
4026
+ function freeFolder(wanted) {
4027
+ if (!existsSync3(wanted)) return wanted;
4028
+ for (let suffix = 2; ; suffix += 1) if (!existsSync3(`${wanted}-${suffix}`)) return `${wanted}-${suffix}`;
4029
+ }
4030
+ function git2(folder, args) {
4031
+ return new Promise((resolve) => execFile4("git", ["-C", folder, ...args], { timeout: 15e3 }, (error, stdout) => resolve(error ? void 0 : stdout)));
4032
+ }
4033
+
3356
4034
  // src/server/setup/setup-service.ts
3357
- import { execFile as execFile2 } from "node:child_process";
4035
+ import { execFile as execFile5 } from "node:child_process";
3358
4036
  import { stat as stat4 } from "node:fs/promises";
3359
- import os5 from "node:os";
3360
- import path15 from "node:path";
3361
- import { promisify as promisify2 } from "node:util";
3362
- var execFileAsync2 = promisify2(execFile2);
4037
+ import os6 from "node:os";
4038
+ import path20 from "node:path";
4039
+ import { promisify as promisify3 } from "node:util";
4040
+ var execFileAsync3 = promisify3(execFile5);
3363
4041
  var testedClaudeVersion = "2.1.310";
3364
4042
  var SetupService = class {
3365
4043
  constructor(projects, userConfig, connector, routerModel) {
@@ -3421,7 +4099,7 @@ var SetupService = class {
3421
4099
  }
3422
4100
  };
3423
4101
  async function commandVersion(command, args, pattern) {
3424
- const output = await execFileAsync2(command, args, { timeout: 1e4, shell: process.platform === "win32" }).then(
4102
+ const output = await execFileAsync3(command, args, { timeout: 1e4, shell: process.platform === "win32" }).then(
3425
4103
  ({ stdout }) => stdout,
3426
4104
  () => ""
3427
4105
  );
@@ -3437,21 +4115,21 @@ function compareVersions(left, right) {
3437
4115
  return 0;
3438
4116
  }
3439
4117
  function expandHome(folder) {
3440
- return folder === "~" || folder.startsWith("~/") ? path15.join(os5.homedir(), folder.slice(1)) : path15.resolve(folder);
4118
+ return folder === "~" || folder.startsWith("~/") ? path20.join(os6.homedir(), folder.slice(1)) : path20.resolve(folder);
3441
4119
  }
3442
4120
  async function isFolder3(folder) {
3443
4121
  return stat4(folder).then((info) => info.isDirectory(), () => false);
3444
4122
  }
3445
4123
 
3446
4124
  // src/server/setup/input-log.ts
3447
- import { appendFile as appendFile2, readFile as readFile6, writeFile as writeFile6 } from "node:fs/promises";
3448
- import path16 from "node:path";
4125
+ import { appendFile as appendFile2, readFile as readFile8, writeFile as writeFile6 } from "node:fs/promises";
4126
+ import path21 from "node:path";
3449
4127
  var keptLines = 1e3;
3450
4128
  var InputLog = class {
3451
4129
  filePath;
3452
4130
  writing = Promise.resolve();
3453
4131
  constructor(dataDirectory2) {
3454
- this.filePath = path16.join(dataDirectory2, "input-log.jsonl");
4132
+ this.filePath = path21.join(dataDirectory2, "input-log.jsonl");
3455
4133
  }
3456
4134
  record(records) {
3457
4135
  const safe = records.slice(0, 500).map((record2) => JSON.stringify(withoutText(record2))).join("\n");
@@ -3459,7 +4137,7 @@ var InputLog = class {
3459
4137
  this.writing = this.writing.then(async () => {
3460
4138
  await appendFile2(this.filePath, `${safe}
3461
4139
  `, { mode: 384 });
3462
- const lines = (await readFile6(this.filePath, "utf8")).split("\n").filter(Boolean);
4140
+ const lines = (await readFile8(this.filePath, "utf8")).split("\n").filter(Boolean);
3463
4141
  if (lines.length > keptLines * 2) await writeFile6(this.filePath, `${lines.slice(-keptLines).join("\n")}
3464
4142
  `, { mode: 384 });
3465
4143
  }).catch(() => void 0);
@@ -3552,11 +4230,18 @@ var TerminalHost = class extends EventEmitter5 {
3552
4230
  for (const viewer of hosted.viewers) viewer.send(data);
3553
4231
  });
3554
4232
  child.onExit(() => {
3555
- if (this.terminals.get(session.name) !== hosted) return;
3556
- this.forget(session.name, hosted);
3557
- this.emit("exit", session.name);
4233
+ const currentName = [...this.terminals].find(([, candidate]) => candidate === hosted)?.[0];
4234
+ if (currentName === void 0) return;
4235
+ this.forget(currentName, hosted);
4236
+ this.emit("exit", currentName);
3558
4237
  });
3559
4238
  }
4239
+ renameSession(currentName, newName) {
4240
+ const hosted = this.terminals.get(currentName);
4241
+ if (!hosted || this.terminals.has(newName)) return;
4242
+ this.terminals.delete(currentName);
4243
+ this.terminals.set(newName, hosted);
4244
+ }
3560
4245
  killSession(name) {
3561
4246
  const hosted = this.terminals.get(name);
3562
4247
  if (!hosted) return;
@@ -3685,7 +4370,7 @@ var SpokenLineDeduplicator = class {
3685
4370
  };
3686
4371
 
3687
4372
  // src/server/main.ts
3688
- var execFileAsync3 = promisify3(execFile3);
4373
+ var execFileAsync4 = promisify4(execFile6);
3689
4374
  var closedPageGraceMilliseconds = 15e3;
3690
4375
  async function startHeyTerm() {
3691
4376
  process.on("unhandledRejection", (reason) => console.error("HeyTerm caught an unexpected error:", reason instanceof Error ? reason.message : reason));
@@ -3694,8 +4379,12 @@ async function startHeyTerm() {
3694
4379
  const userConfig = new UserConfig(config.dataDirectory);
3695
4380
  const savedChoices = await userConfig.load();
3696
4381
  const hooks = await new HookInstaller(config.dataDirectory, config.port).install();
3697
- const agentHookFiles = new AgentHookFiles(hooks.forwardScriptPath, path17.join(config.dataDirectory, "backups"));
4382
+ const agentHookFiles = new AgentHookFiles(hooks.forwardScriptPath, path22.join(config.dataDirectory, "backups"));
3698
4383
  await agentHookFiles.install().catch((error) => console.error(`Could not add HeyTerm's Grok and Cursor hooks: ${error.message}`));
4384
+ const quietTimer = setInterval(() => registry.noticeQuietSessions(Date.now()), 3e4);
4385
+ const hookRepairTimer = setInterval(() => {
4386
+ agentHookFiles.repairIfRemoved().then((repaired) => repaired && console.log("Put HeyTerm's Grok and Cursor hooks back; something had removed them.")).catch(() => void 0);
4387
+ }, 3e4);
3699
4388
  await stopSessionsFromOlderVersion(config.tmuxSocketName);
3700
4389
  const host = new TerminalHost();
3701
4390
  const heldRequests = new HeldHookRequests();
@@ -3703,26 +4392,30 @@ async function startHeyTerm() {
3703
4392
  const registry = new SessionRegistry(store, heldRequests);
3704
4393
  const saved = await store.load();
3705
4394
  registry.restore(saved.sessions, saved.recentlyClosed);
3706
- const launcher = new SessionLauncher(host, registry, { claudeSettingsPath: hooks.settingsPath, forwardScriptPath: hooks.forwardScriptPath });
4395
+ registry.useTurnChangeTracker(new TurnChangeTracker());
4396
+ const launcher = new SessionLauncher(host, registry, { claudeSettingsPath: hooks.settingsPath, forwardScriptPath: hooks.forwardScriptPath }, new SessionWorktrees(config.dataDirectory));
3707
4397
  launcher.restoreAfterStart();
3708
4398
  const trustWatcher = new TrustDialogWatcher(host, registry);
3709
4399
  trustWatcher.start();
4400
+ const claudeSessionWatcher = new ClaudeSessionWatcher(registry);
4401
+ claudeSessionWatcher.start();
3710
4402
  const projects = new ProjectCatalog(config.projectRoots);
3711
4403
  const audit = new AuditLog(config.dataDirectory);
3712
4404
  const decisions = new DecisionActions(registry, heldRequests, audit);
3713
4405
  const sessionActions = new SessionActions(registry, launcher, projects, audit);
3714
4406
  const executor = new ActionExecutor(registry, projects, new FastPathRouter({ sendUnaddressedWordsToFocusedSession: false }), decisions, sessionActions);
3715
- const assistant = createAssistant({ model: config.routerModel, workingDirectory: os6.homedir(), registry, launcher, projects, sessionActions, decisions, executor, audit });
4407
+ const assistant = createAssistant({ model: config.routerModel, workingDirectory: os7.homedir(), memory: new AssistantMemory(config.dataDirectory), registry, launcher, projects, sessionActions, decisions, executor, audit });
3716
4408
  executor.attachAssistant(assistant);
3717
4409
  executor.on("feedback", (feedback) => assistant.recordOutcome(feedback.text));
3718
4410
  registry.on("changed", () => assistant.noteSessions(registry.list()));
3719
4411
  assistant.noteSessions(registry.list());
3720
- const connector = new ExistingSessionConnector(hooks.forwardScriptPath, path17.join(config.dataDirectory, "backups"));
4412
+ const connector = new ExistingSessionConnector(hooks.forwardScriptPath, path22.join(config.dataDirectory, "backups"));
3721
4413
  const showOwnTerminalSessions = savedChoices.showOwnTerminalSessions ?? await connector.isConnected();
3722
4414
  if (savedChoices.showOwnTerminalSessions === void 0 && showOwnTerminalSessions) await userConfig.update({ showOwnTerminalSessions });
3723
4415
  if (showOwnTerminalSessions) await connector.connect().catch((error) => console.error(error.message));
3724
4416
  const setup = new SetupService(projects, userConfig, connector, config.routerModel);
3725
- const hub = new EventHub(registry, executor, projects, setup, new MacVoice(), assistant);
4417
+ const pageEntry = PageBuild.entryFromIndexHtml(await readFile9(path22.join(config.webDirectory, "index.html"), "utf8").catch(() => ""));
4418
+ const hub = new EventHub(registry, executor, projects, setup, new MacVoice(), assistant, pageEntry);
3726
4419
  hub.inputLog = new InputLog(config.dataDirectory);
3727
4420
  const guard = new RequestGuard(config.port, hooks.token, config.developmentOrigin);
3728
4421
  const tickets = new PairingTickets(hooks.token);
@@ -3735,7 +4428,10 @@ async function startHeyTerm() {
3735
4428
  console.log(`
3736
4429
  Stopping HeyTerm (${reason}).`);
3737
4430
  clearTimeout(closedPageTimer);
4431
+ clearInterval(hookRepairTimer);
4432
+ clearInterval(quietTimer);
3738
4433
  trustWatcher.stop();
4434
+ claudeSessionWatcher.stop();
3739
4435
  assistant.close();
3740
4436
  launcher.shutdown();
3741
4437
  if ((await userConfig.load()).showOwnTerminalSessions) await connector.disconnect().catch(() => void 0);
@@ -3773,44 +4469,44 @@ Stopping HeyTerm (${reason}).`);
3773
4469
  }
3774
4470
  async function stopSessionsFromOlderVersion(tmuxSocketName) {
3775
4471
  if (process.platform === "win32") return;
3776
- await execFileAsync3("tmux", ["-L", tmuxSocketName, "kill-server"], { timeout: 5e3 }).catch(() => void 0);
4472
+ await execFileAsync4("tmux", ["-L", tmuxSocketName, "kill-server"], { timeout: 5e3 }).catch(() => void 0);
3777
4473
  }
3778
4474
 
3779
4475
  // src/server/setup/trash.ts
3780
- import { execFile as execFile4 } from "node:child_process";
4476
+ import { execFile as execFile7 } from "node:child_process";
3781
4477
  import { mkdir as mkdir8, rename as rename5, stat as stat5, writeFile as writeFile7 } from "node:fs/promises";
3782
- import os7 from "node:os";
3783
- import path18 from "node:path";
3784
- import { promisify as promisify4 } from "node:util";
3785
- var execFileAsync4 = promisify4(execFile4);
4478
+ import os8 from "node:os";
4479
+ import path23 from "node:path";
4480
+ import { promisify as promisify5 } from "node:util";
4481
+ var execFileAsync5 = promisify5(execFile7);
3786
4482
  async function moveToTrash(target) {
3787
- if (process.platform === "darwin") return moveIntoFolder(target, path18.join(os7.homedir(), ".Trash"));
4483
+ if (process.platform === "darwin") return moveIntoFolder(target, path23.join(os8.homedir(), ".Trash"));
3788
4484
  if (process.platform === "win32") return moveToRecycleBin(target);
3789
4485
  return moveToFreedesktopTrash(target);
3790
4486
  }
3791
4487
  async function moveIntoFolder(target, trashFolder) {
3792
- await rename5(target, await freeName(trashFolder, path18.basename(target)));
4488
+ await rename5(target, await freeName(trashFolder, path23.basename(target)));
3793
4489
  }
3794
4490
  async function moveToFreedesktopTrash(target) {
3795
- const trashHome = path18.join(process.env.XDG_DATA_HOME ?? path18.join(os7.homedir(), ".local", "share"), "Trash");
3796
- await mkdir8(path18.join(trashHome, "files"), { recursive: true });
3797
- await mkdir8(path18.join(trashHome, "info"), { recursive: true });
3798
- const destination = await freeName(path18.join(trashHome, "files"), path18.basename(target));
4491
+ const trashHome = path23.join(process.env.XDG_DATA_HOME ?? path23.join(os8.homedir(), ".local", "share"), "Trash");
4492
+ await mkdir8(path23.join(trashHome, "files"), { recursive: true });
4493
+ await mkdir8(path23.join(trashHome, "info"), { recursive: true });
4494
+ const destination = await freeName(path23.join(trashHome, "files"), path23.basename(target));
3799
4495
  const info = `[Trash Info]
3800
4496
  Path=${encodeURI(target)}
3801
4497
  DeletionDate=${(/* @__PURE__ */ new Date()).toISOString().slice(0, 19)}
3802
4498
  `;
3803
- await writeFile7(path18.join(trashHome, "info", `${path18.basename(destination)}.trashinfo`), info);
4499
+ await writeFile7(path23.join(trashHome, "info", `${path23.basename(destination)}.trashinfo`), info);
3804
4500
  await rename5(target, destination);
3805
4501
  }
3806
4502
  async function moveToRecycleBin(target) {
3807
4503
  const quoted2 = target.replaceAll("'", "''");
3808
4504
  const script = `Add-Type -AssemblyName Microsoft.VisualBasic; [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory('${quoted2}', 'OnlyErrorDialogs', 'SendToRecycleBin')`;
3809
- await execFileAsync4("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeout: 3e4 });
4505
+ await execFileAsync5("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeout: 3e4 });
3810
4506
  }
3811
4507
  async function freeName(folder, name) {
3812
- let candidate = path18.join(folder, name);
3813
- for (let number = 2; await exists2(candidate); number += 1) candidate = path18.join(folder, `${name} ${number}`);
4508
+ let candidate = path23.join(folder, name);
4509
+ for (let number = 2; await exists2(candidate); number += 1) candidate = path23.join(folder, `${name} ${number}`);
3814
4510
  return candidate;
3815
4511
  }
3816
4512
  async function exists2(target) {
@@ -3821,7 +4517,7 @@ async function exists2(target) {
3821
4517
  }
3822
4518
 
3823
4519
  // src/server/setup/uninstaller.ts
3824
- import { readFile as readFile7, rename as rename6, writeFile as writeFile8 } from "node:fs/promises";
4520
+ import { readFile as readFile10, rename as rename6, writeFile as writeFile8 } from "node:fs/promises";
3825
4521
  var Uninstaller = class {
3826
4522
  constructor(options) {
3827
4523
  this.options = options;
@@ -3840,7 +4536,7 @@ var Uninstaller = class {
3840
4536
  return report;
3841
4537
  }
3842
4538
  async removeHooks() {
3843
- const text2 = await readFile7(this.options.claudeSettingsPath, "utf8").catch(() => void 0);
4539
+ const text2 = await readFile10(this.options.claudeSettingsPath, "utf8").catch(() => void 0);
3844
4540
  if (text2 === void 0) return false;
3845
4541
  let settings;
3846
4542
  try {
@@ -3858,7 +4554,7 @@ var Uninstaller = class {
3858
4554
  };
3859
4555
 
3860
4556
  // src/server/cli.ts
3861
- var execFileAsync5 = promisify5(execFile5);
4557
+ var execFileAsync6 = promisify6(execFile8);
3862
4558
  var helpText = `HeyTerm: run your coding agents by voice.
3863
4559
 
3864
4560
  Usage:
@@ -3955,18 +4651,18 @@ function baseUrl() {
3955
4651
  return `http://127.0.0.1:${process.env.HEYTERM_PORT ?? 4747}`;
3956
4652
  }
3957
4653
  function dataDirectory() {
3958
- return process.env.HEYTERM_DATA_DIRECTORY ?? path19.join(os8.homedir(), ".heyterm");
4654
+ return process.env.HEYTERM_DATA_DIRECTORY ?? path24.join(os9.homedir(), ".heyterm");
3959
4655
  }
3960
4656
  async function readToken() {
3961
- const folders = [dataDirectory(), path19.join(os8.homedir(), ".callsign")];
4657
+ const folders = [dataDirectory(), path24.join(os9.homedir(), ".callsign")];
3962
4658
  for (const folder of folders) {
3963
- const token = (await readFile8(path19.join(folder, "token"), "utf8").catch(() => "")).trim();
4659
+ const token = (await readFile11(path24.join(folder, "token"), "utf8").catch(() => "")).trim();
3964
4660
  if (token) return token;
3965
4661
  }
3966
4662
  return void 0;
3967
4663
  }
3968
4664
  async function versionOf(program) {
3969
- const output = await execFileAsync5(program, ["--version"], { timeout: 1e4, shell: process.platform === "win32" }).then(
4665
+ const output = await execFileAsync6(program, ["--version"], { timeout: 1e4, shell: process.platform === "win32" }).then(
3970
4666
  ({ stdout }) => stdout,
3971
4667
  () => ""
3972
4668
  );