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.
- package/README.md +14 -4
- package/dist/server/cli.js +843 -147
- package/dist/web/assets/index-BpHCz2ZQ.css +1 -0
- package/dist/web/assets/index-C23Ju3TU.js +108 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-B8NXrT8_.css +0 -1
- package/dist/web/assets/index-DN5rav7f.js +0 -108
package/dist/server/cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// src/server/cli.ts
|
|
2
|
-
import { execFile as
|
|
3
|
-
import { readFile as
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import { promisify as
|
|
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
|
|
311
|
-
import
|
|
312
|
-
import
|
|
313
|
-
import
|
|
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
|
-
|
|
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
|
|
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
|
|
1535
|
-
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
2091
|
-
const newFolder =
|
|
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
|
|
2111
|
-
import
|
|
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 =
|
|
2376
|
+
this.filePath = path8.join(dataDirectory2, "config.json");
|
|
2116
2377
|
}
|
|
2117
2378
|
async load() {
|
|
2118
2379
|
try {
|
|
2119
|
-
return JSON.parse(await
|
|
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(
|
|
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 =
|
|
2137
|
-
return existsSync(
|
|
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 ??
|
|
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(
|
|
2145
|
-
const projectRoots = environmentRoots ?? saved.projectRoots ?? defaultProjectRoots.map((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
|
|
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
|
|
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
|
-
|
|
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) => !
|
|
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 ??
|
|
2231
|
-
cursorPath: process.env.HEYTERM_CURSOR_HOOKS_PATH ??
|
|
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
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
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(
|
|
2536
|
+
if (await isFolder2(path10.dirname(this.paths.cursorPath))) {
|
|
2249
2537
|
const current = await readJson(this.paths.cursorPath);
|
|
2250
|
-
if (!current)
|
|
2251
|
-
|
|
2252
|
-
|
|
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,
|
|
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
|
|
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) => ({
|
|
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
|
|
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 =
|
|
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:
|
|
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("~") ||
|
|
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:
|
|
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 =
|
|
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) ? `${
|
|
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
|
|
3067
|
+
import path12 from "node:path";
|
|
2743
3068
|
var AuditLog = class {
|
|
2744
3069
|
filePath;
|
|
2745
3070
|
constructor(dataDirectory2) {
|
|
2746
|
-
this.filePath =
|
|
3071
|
+
this.filePath = path12.join(dataDirectory2, "audit.jsonl");
|
|
2747
3072
|
}
|
|
2748
3073
|
async record(entry) {
|
|
2749
|
-
await mkdir6(
|
|
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
|
|
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
|
|
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
|
-
//
|
|
2907
|
-
//
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
3349
|
+
import path16 from "node:path";
|
|
2992
3350
|
|
|
2993
3351
|
// src/server/sessions/agent-session-match.ts
|
|
2994
|
-
import
|
|
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 =
|
|
3001
|
-
const unclaimed = ours.filter((session) => !session.agentSessionId &&
|
|
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
|
|
3423
|
+
import path15 from "node:path";
|
|
3008
3424
|
var ExternalSessionNaming = class {
|
|
3009
3425
|
static nameFor(facts, takenNames) {
|
|
3010
|
-
const base = slug(facts.sessionTitle ?? "") || slug(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
3182
|
-
return
|
|
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
|
|
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:
|
|
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
|
|
3286
|
-
import
|
|
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 =
|
|
3800
|
+
this.filePath = path17.join(dataDirectory2, "state.json");
|
|
3295
3801
|
}
|
|
3296
3802
|
async load() {
|
|
3297
|
-
const text2 = await
|
|
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(
|
|
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
|
|
4035
|
+
import { execFile as execFile5 } from "node:child_process";
|
|
3358
4036
|
import { stat as stat4 } from "node:fs/promises";
|
|
3359
|
-
import
|
|
3360
|
-
import
|
|
3361
|
-
import { promisify as
|
|
3362
|
-
var
|
|
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
|
|
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("~/") ?
|
|
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
|
|
3448
|
-
import
|
|
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 =
|
|
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
|
|
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
|
-
|
|
3556
|
-
|
|
3557
|
-
this.
|
|
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
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3783
|
-
import
|
|
3784
|
-
import { promisify as
|
|
3785
|
-
var
|
|
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,
|
|
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,
|
|
4488
|
+
await rename5(target, await freeName(trashFolder, path23.basename(target)));
|
|
3793
4489
|
}
|
|
3794
4490
|
async function moveToFreedesktopTrash(target) {
|
|
3795
|
-
const trashHome =
|
|
3796
|
-
await mkdir8(
|
|
3797
|
-
await mkdir8(
|
|
3798
|
-
const destination = await freeName(
|
|
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(
|
|
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
|
|
4505
|
+
await execFileAsync5("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeout: 3e4 });
|
|
3810
4506
|
}
|
|
3811
4507
|
async function freeName(folder, name) {
|
|
3812
|
-
let candidate =
|
|
3813
|
-
for (let number = 2; await exists2(candidate); number += 1) candidate =
|
|
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
|
|
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
|
|
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
|
|
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 ??
|
|
4654
|
+
return process.env.HEYTERM_DATA_DIRECTORY ?? path24.join(os9.homedir(), ".heyterm");
|
|
3959
4655
|
}
|
|
3960
4656
|
async function readToken() {
|
|
3961
|
-
const folders = [dataDirectory(),
|
|
4657
|
+
const folders = [dataDirectory(), path24.join(os9.homedir(), ".callsign")];
|
|
3962
4658
|
for (const folder of folders) {
|
|
3963
|
-
const token = (await
|
|
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
|
|
4665
|
+
const output = await execFileAsync6(program, ["--version"], { timeout: 1e4, shell: process.platform === "win32" }).then(
|
|
3970
4666
|
({ stdout }) => stdout,
|
|
3971
4667
|
() => ""
|
|
3972
4668
|
);
|