heyterm 0.1.1 → 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 +18 -5
- package/dist/server/cli.js +848 -149
- 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,14 +3079,15 @@ 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/;
|
|
2763
3088
|
var hookReviewText = /Hooks need review/;
|
|
2764
3089
|
var grokTrustText = /Do you trust the contents of this directory\?[\s\S]*Yes, proceed\s+y\b/;
|
|
3090
|
+
var cursorTrustText = /Do you trust the contents of this directory\?[\s\S]*\[a\] Trust this workspace[\s\S]*press the key shown/;
|
|
2765
3091
|
var selectionMarkers = ["\u276F", "\u203A"];
|
|
2766
3092
|
var greatestOptionDistance = 4;
|
|
2767
3093
|
var ScreenReader = class _ScreenReader {
|
|
@@ -2771,6 +3097,7 @@ var ScreenReader = class _ScreenReader {
|
|
|
2771
3097
|
// Keys that select "Yes, I trust this folder" and confirm it, or undefined when the dialog is not showing.
|
|
2772
3098
|
static trustDialogKeys(screenText) {
|
|
2773
3099
|
if (grokTrustText.test(screenText)) return ["y"];
|
|
3100
|
+
if (cursorTrustText.test(screenText)) return ["a"];
|
|
2774
3101
|
const lines = screenText.split("\n");
|
|
2775
3102
|
const trustLineIndex = lines.findIndex((line) => trustOptionText.test(line));
|
|
2776
3103
|
if (trustLineIndex === -1) return void 0;
|
|
@@ -2791,6 +3118,27 @@ var ScreenReader = class _ScreenReader {
|
|
|
2791
3118
|
}
|
|
2792
3119
|
};
|
|
2793
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
|
+
|
|
2794
3142
|
// src/server/terminals/launch-command.ts
|
|
2795
3143
|
var LaunchCommand = class {
|
|
2796
3144
|
static forPlatform(program, args, platform = process.platform) {
|
|
@@ -2809,28 +3157,33 @@ function windowsQuote(value) {
|
|
|
2809
3157
|
}
|
|
2810
3158
|
|
|
2811
3159
|
// src/server/sessions/session-launcher.ts
|
|
2812
|
-
var
|
|
3160
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
2813
3161
|
var promptConfirmationMilliseconds = 2500;
|
|
2814
3162
|
var pasteSettleMilliseconds = 150;
|
|
2815
3163
|
var AgentNotInstalledError = class extends Error {
|
|
2816
3164
|
};
|
|
2817
3165
|
var SessionLauncher = class {
|
|
2818
|
-
constructor(host, registry, paths) {
|
|
3166
|
+
constructor(host, registry, paths, worktrees) {
|
|
2819
3167
|
this.host = host;
|
|
2820
3168
|
this.registry = registry;
|
|
2821
3169
|
this.paths = paths;
|
|
3170
|
+
this.worktrees = worktrees;
|
|
2822
3171
|
host.on("exit", (name) => this.registry.remove(name));
|
|
2823
3172
|
}
|
|
2824
3173
|
host;
|
|
2825
3174
|
registry;
|
|
2826
3175
|
paths;
|
|
3176
|
+
worktrees;
|
|
2827
3177
|
async start(projectPath, projectName, task, agent = "claude") {
|
|
2828
3178
|
await this.ensureInstalled(agent);
|
|
2829
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;
|
|
2830
3182
|
const now = Date.now();
|
|
2831
3183
|
const session = {
|
|
2832
3184
|
name,
|
|
2833
|
-
projectPath,
|
|
3185
|
+
projectPath: made?.path ?? projectPath,
|
|
3186
|
+
...made ? { worktree: { repoPath: made.repoPath, worktreePath: made.worktreePath, branch: made.branch, baseCommit: made.baseCommit } } : {},
|
|
2834
3187
|
projectName,
|
|
2835
3188
|
agent,
|
|
2836
3189
|
state: "starting",
|
|
@@ -2860,6 +3213,16 @@ var SessionLauncher = class {
|
|
|
2860
3213
|
this.launch(session, adapter.resume(point, { ...this.launchContext(session.name), agentSessionId: launchId }));
|
|
2861
3214
|
return session;
|
|
2862
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
|
+
}
|
|
2863
3226
|
async close(name) {
|
|
2864
3227
|
this.host.killSession(name);
|
|
2865
3228
|
this.registry.remove(name);
|
|
@@ -2895,19 +3258,16 @@ var SessionLauncher = class {
|
|
|
2895
3258
|
async hasUncommittedChanges(name) {
|
|
2896
3259
|
const session = this.registry.get(name);
|
|
2897
3260
|
if (!session) return false;
|
|
2898
|
-
const status = await
|
|
3261
|
+
const status = await execFileAsync2("git", ["-C", session.projectPath, "status", "--porcelain"], { timeout: 5e3 }).then(
|
|
2899
3262
|
({ stdout }) => stdout.trim(),
|
|
2900
3263
|
() => ""
|
|
2901
3264
|
);
|
|
2902
3265
|
return status.length > 0;
|
|
2903
3266
|
}
|
|
2904
|
-
//
|
|
2905
|
-
//
|
|
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.
|
|
2906
3269
|
restoreAfterStart() {
|
|
2907
|
-
for (const session of this.registry.list())
|
|
2908
|
-
if (session.origin === "external") this.registry.forget(session.name);
|
|
2909
|
-
else this.registry.remove(session.name);
|
|
2910
|
-
}
|
|
3270
|
+
for (const session of this.registry.list()) if (session.origin !== "external") this.registry.remove(session.name);
|
|
2911
3271
|
}
|
|
2912
3272
|
// HeyTerm is stopping: every session ends and waits under "Recently closed" for next time.
|
|
2913
3273
|
shutdown() {
|
|
@@ -2957,16 +3317,16 @@ var SessionLauncher = class {
|
|
|
2957
3317
|
}
|
|
2958
3318
|
uniqueName(projectName) {
|
|
2959
3319
|
const base = projectName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "session";
|
|
2960
|
-
if (!this.registry.
|
|
3320
|
+
if (!this.registry.isNameInUse(base)) return base;
|
|
2961
3321
|
for (let suffix = 2; ; suffix += 1) {
|
|
2962
3322
|
const candidate = `${base}-${suffix}`;
|
|
2963
|
-
if (!this.registry.
|
|
3323
|
+
if (!this.registry.isNameInUse(candidate)) return candidate;
|
|
2964
3324
|
}
|
|
2965
3325
|
}
|
|
2966
3326
|
};
|
|
2967
3327
|
async function isOnPath(program) {
|
|
2968
3328
|
const finder = process.platform === "win32" ? "where" : "which";
|
|
2969
|
-
return
|
|
3329
|
+
return execFileAsync2(finder, [program], { timeout: 5e3 }).then(
|
|
2970
3330
|
() => true,
|
|
2971
3331
|
() => false
|
|
2972
3332
|
);
|
|
@@ -2986,26 +3346,84 @@ function delay(milliseconds) {
|
|
|
2986
3346
|
// src/server/sessions/session-registry.ts
|
|
2987
3347
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
2988
3348
|
import { EventEmitter as EventEmitter4 } from "node:events";
|
|
2989
|
-
import
|
|
3349
|
+
import path16 from "node:path";
|
|
2990
3350
|
|
|
2991
3351
|
// src/server/sessions/agent-session-match.ts
|
|
2992
|
-
import
|
|
3352
|
+
import path14 from "node:path";
|
|
2993
3353
|
var AgentSessionMatch = class {
|
|
2994
3354
|
static find(sessions, agent, agentSessionId, workingDirectory) {
|
|
2995
3355
|
const ours = sessions.filter((session) => session.origin !== "external" && session.agent === agent);
|
|
2996
3356
|
const known = ours.find((session) => session.agentSessionId === agentSessionId);
|
|
2997
3357
|
if (known) return { session: known, claimsId: false };
|
|
2998
|
-
const folder =
|
|
2999
|
-
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);
|
|
3000
3360
|
return unclaimed[0] ? { session: unclaimed[0], claimsId: true } : void 0;
|
|
3001
3361
|
}
|
|
3002
3362
|
};
|
|
3003
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
|
+
|
|
3004
3422
|
// src/server/sessions/external-session-naming.ts
|
|
3005
|
-
import
|
|
3423
|
+
import path15 from "node:path";
|
|
3006
3424
|
var ExternalSessionNaming = class {
|
|
3007
3425
|
static nameFor(facts, takenNames) {
|
|
3008
|
-
const base = slug(facts.sessionTitle ?? "") || slug(
|
|
3426
|
+
const base = slug(facts.sessionTitle ?? "") || slug(path15.basename(facts.workingDirectory)) || "session";
|
|
3009
3427
|
if (!takenNames.includes(base)) return base;
|
|
3010
3428
|
for (let suffix = 2; ; suffix += 1) {
|
|
3011
3429
|
const candidate = `${base}-${suffix}`;
|
|
@@ -3123,12 +3541,20 @@ var SessionRegistry = class extends EventEmitter4 {
|
|
|
3123
3541
|
sessions = /* @__PURE__ */ new Map();
|
|
3124
3542
|
recentlyClosed = [];
|
|
3125
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();
|
|
3126
3547
|
dayKey = localDayKey(Date.now());
|
|
3127
3548
|
changeTimer;
|
|
3128
3549
|
saveTimer;
|
|
3129
3550
|
restore(sessions, recentlyClosed) {
|
|
3130
3551
|
for (const session of sessions) this.sessions.set(session.name, session);
|
|
3131
|
-
|
|
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
|
+
});
|
|
3132
3558
|
}
|
|
3133
3559
|
list() {
|
|
3134
3560
|
return [...this.sessions.values()].sort((left, right) => left.createdAt - right.createdAt);
|
|
@@ -3148,10 +3574,23 @@ var SessionRegistry = class extends EventEmitter4 {
|
|
|
3148
3574
|
if (!session) return;
|
|
3149
3575
|
this.sessions.delete(name);
|
|
3150
3576
|
this.heldRequests.letGoAllFor(name);
|
|
3151
|
-
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 };
|
|
3152
3579
|
this.recentlyClosed = [closed, ...this.recentlyClosed].slice(0, recentlyClosedLimit);
|
|
3153
3580
|
this.announceChange();
|
|
3154
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
|
+
}
|
|
3155
3594
|
// A session that left while HeyTerm was away; it is not worth a "recently closed" row.
|
|
3156
3595
|
forget(name) {
|
|
3157
3596
|
if (!this.sessions.delete(name)) return;
|
|
@@ -3165,19 +3604,61 @@ var SessionRegistry = class extends EventEmitter4 {
|
|
|
3165
3604
|
this.set(updated);
|
|
3166
3605
|
return updated;
|
|
3167
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
|
+
}
|
|
3168
3611
|
// Returns what the hook script should print: a decision for a held request, otherwise nothing.
|
|
3169
3612
|
async applyHookEvent(event) {
|
|
3170
3613
|
this.resetWaitsOnNewDay(event.receivedAt);
|
|
3171
|
-
const
|
|
3614
|
+
const sessionName = this.currentName(event.sessionName);
|
|
3615
|
+
const before = this.sessions.get(sessionName);
|
|
3172
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(() => []) : [];
|
|
3173
3619
|
const requestId = event.name === "PermissionRequest" ? randomUUID2() : void 0;
|
|
3174
|
-
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;
|
|
3175
3622
|
this.set(after);
|
|
3176
3623
|
if (before.pending && before.pending.requestId !== after.pending?.requestId) {
|
|
3177
|
-
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);
|
|
3178
3652
|
}
|
|
3179
|
-
|
|
3180
|
-
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;
|
|
3181
3662
|
}
|
|
3182
3663
|
// An event from Grok Build or Cursor, which run HeyTerm's hooks from a global file in every session.
|
|
3183
3664
|
// Only the sessions HeyTerm started move; the reply is what the hook prints.
|
|
@@ -3191,6 +3672,26 @@ var SessionRegistry = class extends EventEmitter4 {
|
|
|
3191
3672
|
const answer = await this.applyHookEvent({ name: event.name, sessionName: found.session.name, payload: event.payload, receivedAt });
|
|
3192
3673
|
return answer || defaultReply;
|
|
3193
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
|
+
}
|
|
3194
3695
|
// An event from a session the user started in their own terminal, known by its Claude session id.
|
|
3195
3696
|
async applyExternalHookEvent(name, payload, receivedAt) {
|
|
3196
3697
|
const agentSessionId = typeof payload.session_id === "string" ? payload.session_id : "";
|
|
@@ -3208,12 +3709,13 @@ var SessionRegistry = class extends EventEmitter4 {
|
|
|
3208
3709
|
newExternalSession(agentSessionId, payload, now) {
|
|
3209
3710
|
const workingDirectory = typeof payload.cwd === "string" ? payload.cwd : "";
|
|
3210
3711
|
const sessionTitle = typeof payload.session_title === "string" ? payload.session_title : void 0;
|
|
3211
|
-
const
|
|
3712
|
+
const takenNames = [...this.sessions.keys(), ...this.launchNames.keys(), ...this.recentlyClosed.map((closed) => closed.name)];
|
|
3713
|
+
const name = ExternalSessionNaming.nameFor({ sessionTitle, workingDirectory }, takenNames);
|
|
3212
3714
|
return {
|
|
3213
3715
|
name,
|
|
3214
3716
|
origin: "external",
|
|
3215
3717
|
projectPath: workingDirectory,
|
|
3216
|
-
projectName:
|
|
3718
|
+
projectName: path16.basename(workingDirectory) || name,
|
|
3217
3719
|
agent: "claude",
|
|
3218
3720
|
state: "idle",
|
|
3219
3721
|
stateSince: now,
|
|
@@ -3277,11 +3779,17 @@ var SessionRegistry = class extends EventEmitter4 {
|
|
|
3277
3779
|
function localDayKey(time) {
|
|
3278
3780
|
return new Date(time).toDateString();
|
|
3279
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
|
+
}
|
|
3280
3788
|
|
|
3281
3789
|
// src/server/sessions/state-store.ts
|
|
3282
3790
|
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
3283
|
-
import { mkdir as mkdir7, readFile as
|
|
3284
|
-
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";
|
|
3285
3793
|
function upgraded(saved) {
|
|
3286
3794
|
const { claudeSessionId, ...session } = saved;
|
|
3287
3795
|
return { ...session, agent: session.agent ?? "claude", agentSessionId: session.agentSessionId ?? claudeSessionId };
|
|
@@ -3289,10 +3797,10 @@ function upgraded(saved) {
|
|
|
3289
3797
|
var StateStore = class {
|
|
3290
3798
|
filePath;
|
|
3291
3799
|
constructor(dataDirectory2) {
|
|
3292
|
-
this.filePath =
|
|
3800
|
+
this.filePath = path17.join(dataDirectory2, "state.json");
|
|
3293
3801
|
}
|
|
3294
3802
|
async load() {
|
|
3295
|
-
const text2 = await
|
|
3803
|
+
const text2 = await readFile6(this.filePath, "utf8").catch(() => "");
|
|
3296
3804
|
if (!text2) return { sessions: [], recentlyClosed: [] };
|
|
3297
3805
|
try {
|
|
3298
3806
|
const parsed = JSON.parse(text2);
|
|
@@ -3310,7 +3818,7 @@ var StateStore = class {
|
|
|
3310
3818
|
}
|
|
3311
3819
|
saving = Promise.resolve();
|
|
3312
3820
|
async write(state) {
|
|
3313
|
-
await mkdir7(
|
|
3821
|
+
await mkdir7(path17.dirname(this.filePath), { recursive: true });
|
|
3314
3822
|
const temporaryPath = `${this.filePath}.${process.pid}.${randomUUID3()}.tmp`;
|
|
3315
3823
|
await writeFile5(temporaryPath, JSON.stringify(state), { mode: 384 });
|
|
3316
3824
|
await rename4(temporaryPath, this.filePath);
|
|
@@ -3351,13 +3859,185 @@ var TrustDialogWatcher = class {
|
|
|
3351
3859
|
}
|
|
3352
3860
|
};
|
|
3353
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
|
+
|
|
3354
4034
|
// src/server/setup/setup-service.ts
|
|
3355
|
-
import { execFile as
|
|
4035
|
+
import { execFile as execFile5 } from "node:child_process";
|
|
3356
4036
|
import { stat as stat4 } from "node:fs/promises";
|
|
3357
|
-
import
|
|
3358
|
-
import
|
|
3359
|
-
import { promisify as
|
|
3360
|
-
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);
|
|
3361
4041
|
var testedClaudeVersion = "2.1.310";
|
|
3362
4042
|
var SetupService = class {
|
|
3363
4043
|
constructor(projects, userConfig, connector, routerModel) {
|
|
@@ -3419,7 +4099,7 @@ var SetupService = class {
|
|
|
3419
4099
|
}
|
|
3420
4100
|
};
|
|
3421
4101
|
async function commandVersion(command, args, pattern) {
|
|
3422
|
-
const output = await
|
|
4102
|
+
const output = await execFileAsync3(command, args, { timeout: 1e4, shell: process.platform === "win32" }).then(
|
|
3423
4103
|
({ stdout }) => stdout,
|
|
3424
4104
|
() => ""
|
|
3425
4105
|
);
|
|
@@ -3435,21 +4115,21 @@ function compareVersions(left, right) {
|
|
|
3435
4115
|
return 0;
|
|
3436
4116
|
}
|
|
3437
4117
|
function expandHome(folder) {
|
|
3438
|
-
return folder === "~" || folder.startsWith("~/") ?
|
|
4118
|
+
return folder === "~" || folder.startsWith("~/") ? path20.join(os6.homedir(), folder.slice(1)) : path20.resolve(folder);
|
|
3439
4119
|
}
|
|
3440
4120
|
async function isFolder3(folder) {
|
|
3441
4121
|
return stat4(folder).then((info) => info.isDirectory(), () => false);
|
|
3442
4122
|
}
|
|
3443
4123
|
|
|
3444
4124
|
// src/server/setup/input-log.ts
|
|
3445
|
-
import { appendFile as appendFile2, readFile as
|
|
3446
|
-
import
|
|
4125
|
+
import { appendFile as appendFile2, readFile as readFile8, writeFile as writeFile6 } from "node:fs/promises";
|
|
4126
|
+
import path21 from "node:path";
|
|
3447
4127
|
var keptLines = 1e3;
|
|
3448
4128
|
var InputLog = class {
|
|
3449
4129
|
filePath;
|
|
3450
4130
|
writing = Promise.resolve();
|
|
3451
4131
|
constructor(dataDirectory2) {
|
|
3452
|
-
this.filePath =
|
|
4132
|
+
this.filePath = path21.join(dataDirectory2, "input-log.jsonl");
|
|
3453
4133
|
}
|
|
3454
4134
|
record(records) {
|
|
3455
4135
|
const safe = records.slice(0, 500).map((record2) => JSON.stringify(withoutText(record2))).join("\n");
|
|
@@ -3457,7 +4137,7 @@ var InputLog = class {
|
|
|
3457
4137
|
this.writing = this.writing.then(async () => {
|
|
3458
4138
|
await appendFile2(this.filePath, `${safe}
|
|
3459
4139
|
`, { mode: 384 });
|
|
3460
|
-
const lines = (await
|
|
4140
|
+
const lines = (await readFile8(this.filePath, "utf8")).split("\n").filter(Boolean);
|
|
3461
4141
|
if (lines.length > keptLines * 2) await writeFile6(this.filePath, `${lines.slice(-keptLines).join("\n")}
|
|
3462
4142
|
`, { mode: 384 });
|
|
3463
4143
|
}).catch(() => void 0);
|
|
@@ -3491,8 +4171,9 @@ var keySequences = {
|
|
|
3491
4171
|
Down: "\x1B[B",
|
|
3492
4172
|
Right: "\x1B[C",
|
|
3493
4173
|
Left: "\x1B[D",
|
|
3494
|
-
// Grok Build's dialogs answer to a letter.
|
|
3495
|
-
y: "y"
|
|
4174
|
+
// Grok Build's and Cursor's dialogs answer to a letter.
|
|
4175
|
+
y: "y",
|
|
4176
|
+
a: "a"
|
|
3496
4177
|
};
|
|
3497
4178
|
var pasteStart = "\x1B[200~";
|
|
3498
4179
|
var pasteEnd = "\x1B[201~";
|
|
@@ -3549,11 +4230,18 @@ var TerminalHost = class extends EventEmitter5 {
|
|
|
3549
4230
|
for (const viewer of hosted.viewers) viewer.send(data);
|
|
3550
4231
|
});
|
|
3551
4232
|
child.onExit(() => {
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
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);
|
|
3555
4237
|
});
|
|
3556
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
|
+
}
|
|
3557
4245
|
killSession(name) {
|
|
3558
4246
|
const hosted = this.terminals.get(name);
|
|
3559
4247
|
if (!hosted) return;
|
|
@@ -3682,7 +4370,7 @@ var SpokenLineDeduplicator = class {
|
|
|
3682
4370
|
};
|
|
3683
4371
|
|
|
3684
4372
|
// src/server/main.ts
|
|
3685
|
-
var
|
|
4373
|
+
var execFileAsync4 = promisify4(execFile6);
|
|
3686
4374
|
var closedPageGraceMilliseconds = 15e3;
|
|
3687
4375
|
async function startHeyTerm() {
|
|
3688
4376
|
process.on("unhandledRejection", (reason) => console.error("HeyTerm caught an unexpected error:", reason instanceof Error ? reason.message : reason));
|
|
@@ -3691,8 +4379,12 @@ async function startHeyTerm() {
|
|
|
3691
4379
|
const userConfig = new UserConfig(config.dataDirectory);
|
|
3692
4380
|
const savedChoices = await userConfig.load();
|
|
3693
4381
|
const hooks = await new HookInstaller(config.dataDirectory, config.port).install();
|
|
3694
|
-
const agentHookFiles = new AgentHookFiles(hooks.forwardScriptPath,
|
|
4382
|
+
const agentHookFiles = new AgentHookFiles(hooks.forwardScriptPath, path22.join(config.dataDirectory, "backups"));
|
|
3695
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);
|
|
3696
4388
|
await stopSessionsFromOlderVersion(config.tmuxSocketName);
|
|
3697
4389
|
const host = new TerminalHost();
|
|
3698
4390
|
const heldRequests = new HeldHookRequests();
|
|
@@ -3700,26 +4392,30 @@ async function startHeyTerm() {
|
|
|
3700
4392
|
const registry = new SessionRegistry(store, heldRequests);
|
|
3701
4393
|
const saved = await store.load();
|
|
3702
4394
|
registry.restore(saved.sessions, saved.recentlyClosed);
|
|
3703
|
-
|
|
4395
|
+
registry.useTurnChangeTracker(new TurnChangeTracker());
|
|
4396
|
+
const launcher = new SessionLauncher(host, registry, { claudeSettingsPath: hooks.settingsPath, forwardScriptPath: hooks.forwardScriptPath }, new SessionWorktrees(config.dataDirectory));
|
|
3704
4397
|
launcher.restoreAfterStart();
|
|
3705
4398
|
const trustWatcher = new TrustDialogWatcher(host, registry);
|
|
3706
4399
|
trustWatcher.start();
|
|
4400
|
+
const claudeSessionWatcher = new ClaudeSessionWatcher(registry);
|
|
4401
|
+
claudeSessionWatcher.start();
|
|
3707
4402
|
const projects = new ProjectCatalog(config.projectRoots);
|
|
3708
4403
|
const audit = new AuditLog(config.dataDirectory);
|
|
3709
4404
|
const decisions = new DecisionActions(registry, heldRequests, audit);
|
|
3710
4405
|
const sessionActions = new SessionActions(registry, launcher, projects, audit);
|
|
3711
4406
|
const executor = new ActionExecutor(registry, projects, new FastPathRouter({ sendUnaddressedWordsToFocusedSession: false }), decisions, sessionActions);
|
|
3712
|
-
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 });
|
|
3713
4408
|
executor.attachAssistant(assistant);
|
|
3714
4409
|
executor.on("feedback", (feedback) => assistant.recordOutcome(feedback.text));
|
|
3715
4410
|
registry.on("changed", () => assistant.noteSessions(registry.list()));
|
|
3716
4411
|
assistant.noteSessions(registry.list());
|
|
3717
|
-
const connector = new ExistingSessionConnector(hooks.forwardScriptPath,
|
|
4412
|
+
const connector = new ExistingSessionConnector(hooks.forwardScriptPath, path22.join(config.dataDirectory, "backups"));
|
|
3718
4413
|
const showOwnTerminalSessions = savedChoices.showOwnTerminalSessions ?? await connector.isConnected();
|
|
3719
4414
|
if (savedChoices.showOwnTerminalSessions === void 0 && showOwnTerminalSessions) await userConfig.update({ showOwnTerminalSessions });
|
|
3720
4415
|
if (showOwnTerminalSessions) await connector.connect().catch((error) => console.error(error.message));
|
|
3721
4416
|
const setup = new SetupService(projects, userConfig, connector, config.routerModel);
|
|
3722
|
-
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);
|
|
3723
4419
|
hub.inputLog = new InputLog(config.dataDirectory);
|
|
3724
4420
|
const guard = new RequestGuard(config.port, hooks.token, config.developmentOrigin);
|
|
3725
4421
|
const tickets = new PairingTickets(hooks.token);
|
|
@@ -3732,7 +4428,10 @@ async function startHeyTerm() {
|
|
|
3732
4428
|
console.log(`
|
|
3733
4429
|
Stopping HeyTerm (${reason}).`);
|
|
3734
4430
|
clearTimeout(closedPageTimer);
|
|
4431
|
+
clearInterval(hookRepairTimer);
|
|
4432
|
+
clearInterval(quietTimer);
|
|
3735
4433
|
trustWatcher.stop();
|
|
4434
|
+
claudeSessionWatcher.stop();
|
|
3736
4435
|
assistant.close();
|
|
3737
4436
|
launcher.shutdown();
|
|
3738
4437
|
if ((await userConfig.load()).showOwnTerminalSessions) await connector.disconnect().catch(() => void 0);
|
|
@@ -3770,44 +4469,44 @@ Stopping HeyTerm (${reason}).`);
|
|
|
3770
4469
|
}
|
|
3771
4470
|
async function stopSessionsFromOlderVersion(tmuxSocketName) {
|
|
3772
4471
|
if (process.platform === "win32") return;
|
|
3773
|
-
await
|
|
4472
|
+
await execFileAsync4("tmux", ["-L", tmuxSocketName, "kill-server"], { timeout: 5e3 }).catch(() => void 0);
|
|
3774
4473
|
}
|
|
3775
4474
|
|
|
3776
4475
|
// src/server/setup/trash.ts
|
|
3777
|
-
import { execFile as
|
|
4476
|
+
import { execFile as execFile7 } from "node:child_process";
|
|
3778
4477
|
import { mkdir as mkdir8, rename as rename5, stat as stat5, writeFile as writeFile7 } from "node:fs/promises";
|
|
3779
|
-
import
|
|
3780
|
-
import
|
|
3781
|
-
import { promisify as
|
|
3782
|
-
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);
|
|
3783
4482
|
async function moveToTrash(target) {
|
|
3784
|
-
if (process.platform === "darwin") return moveIntoFolder(target,
|
|
4483
|
+
if (process.platform === "darwin") return moveIntoFolder(target, path23.join(os8.homedir(), ".Trash"));
|
|
3785
4484
|
if (process.platform === "win32") return moveToRecycleBin(target);
|
|
3786
4485
|
return moveToFreedesktopTrash(target);
|
|
3787
4486
|
}
|
|
3788
4487
|
async function moveIntoFolder(target, trashFolder) {
|
|
3789
|
-
await rename5(target, await freeName(trashFolder,
|
|
4488
|
+
await rename5(target, await freeName(trashFolder, path23.basename(target)));
|
|
3790
4489
|
}
|
|
3791
4490
|
async function moveToFreedesktopTrash(target) {
|
|
3792
|
-
const trashHome =
|
|
3793
|
-
await mkdir8(
|
|
3794
|
-
await mkdir8(
|
|
3795
|
-
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));
|
|
3796
4495
|
const info = `[Trash Info]
|
|
3797
4496
|
Path=${encodeURI(target)}
|
|
3798
4497
|
DeletionDate=${(/* @__PURE__ */ new Date()).toISOString().slice(0, 19)}
|
|
3799
4498
|
`;
|
|
3800
|
-
await writeFile7(
|
|
4499
|
+
await writeFile7(path23.join(trashHome, "info", `${path23.basename(destination)}.trashinfo`), info);
|
|
3801
4500
|
await rename5(target, destination);
|
|
3802
4501
|
}
|
|
3803
4502
|
async function moveToRecycleBin(target) {
|
|
3804
4503
|
const quoted2 = target.replaceAll("'", "''");
|
|
3805
4504
|
const script = `Add-Type -AssemblyName Microsoft.VisualBasic; [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory('${quoted2}', 'OnlyErrorDialogs', 'SendToRecycleBin')`;
|
|
3806
|
-
await
|
|
4505
|
+
await execFileAsync5("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeout: 3e4 });
|
|
3807
4506
|
}
|
|
3808
4507
|
async function freeName(folder, name) {
|
|
3809
|
-
let candidate =
|
|
3810
|
-
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}`);
|
|
3811
4510
|
return candidate;
|
|
3812
4511
|
}
|
|
3813
4512
|
async function exists2(target) {
|
|
@@ -3818,7 +4517,7 @@ async function exists2(target) {
|
|
|
3818
4517
|
}
|
|
3819
4518
|
|
|
3820
4519
|
// src/server/setup/uninstaller.ts
|
|
3821
|
-
import { readFile as
|
|
4520
|
+
import { readFile as readFile10, rename as rename6, writeFile as writeFile8 } from "node:fs/promises";
|
|
3822
4521
|
var Uninstaller = class {
|
|
3823
4522
|
constructor(options) {
|
|
3824
4523
|
this.options = options;
|
|
@@ -3837,7 +4536,7 @@ var Uninstaller = class {
|
|
|
3837
4536
|
return report;
|
|
3838
4537
|
}
|
|
3839
4538
|
async removeHooks() {
|
|
3840
|
-
const text2 = await
|
|
4539
|
+
const text2 = await readFile10(this.options.claudeSettingsPath, "utf8").catch(() => void 0);
|
|
3841
4540
|
if (text2 === void 0) return false;
|
|
3842
4541
|
let settings;
|
|
3843
4542
|
try {
|
|
@@ -3855,7 +4554,7 @@ var Uninstaller = class {
|
|
|
3855
4554
|
};
|
|
3856
4555
|
|
|
3857
4556
|
// src/server/cli.ts
|
|
3858
|
-
var
|
|
4557
|
+
var execFileAsync6 = promisify6(execFile8);
|
|
3859
4558
|
var helpText = `HeyTerm: run your coding agents by voice.
|
|
3860
4559
|
|
|
3861
4560
|
Usage:
|
|
@@ -3952,18 +4651,18 @@ function baseUrl() {
|
|
|
3952
4651
|
return `http://127.0.0.1:${process.env.HEYTERM_PORT ?? 4747}`;
|
|
3953
4652
|
}
|
|
3954
4653
|
function dataDirectory() {
|
|
3955
|
-
return process.env.HEYTERM_DATA_DIRECTORY ??
|
|
4654
|
+
return process.env.HEYTERM_DATA_DIRECTORY ?? path24.join(os9.homedir(), ".heyterm");
|
|
3956
4655
|
}
|
|
3957
4656
|
async function readToken() {
|
|
3958
|
-
const folders = [dataDirectory(),
|
|
4657
|
+
const folders = [dataDirectory(), path24.join(os9.homedir(), ".callsign")];
|
|
3959
4658
|
for (const folder of folders) {
|
|
3960
|
-
const token = (await
|
|
4659
|
+
const token = (await readFile11(path24.join(folder, "token"), "utf8").catch(() => "")).trim();
|
|
3961
4660
|
if (token) return token;
|
|
3962
4661
|
}
|
|
3963
4662
|
return void 0;
|
|
3964
4663
|
}
|
|
3965
4664
|
async function versionOf(program) {
|
|
3966
|
-
const output = await
|
|
4665
|
+
const output = await execFileAsync6(program, ["--version"], { timeout: 1e4, shell: process.platform === "win32" }).then(
|
|
3967
4666
|
({ stdout }) => stdout,
|
|
3968
4667
|
() => ""
|
|
3969
4668
|
);
|