@kitfunso/aura 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/decide.js ADDED
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ // Pure decision core. No fs, no child processes, no Win32: hook.js owns all I/O.
3
+ // Rules and the measurements behind them: docs/ARCHITECTURE.md.
4
+ const path = require("path");
5
+
6
+ // A tab the user named is an identity; a title an agent or a shell wrote is not,
7
+ // and reading one of those back would feed the color its own output.
8
+ const MAX_WINDOW_NAME = 40;
9
+ // Every terminal ships these, so they name a shell, never a project.
10
+ const DEFAULT_TITLES = [
11
+ "windows powershell", "powershell", "pwsh", "command prompt", "cmd", "cmd.exe",
12
+ "windows terminal", "git bash", "bash", "sh", "zsh", "node", "ubuntu", "wsl",
13
+ ];
14
+
15
+ function usableWindowTitle(raw) {
16
+ const name = String(raw == null ? "" : raw).replace(/[\u0000-\u001f]+/g, " ").trim();
17
+ if (!name || name.length > MAX_WINDOW_NAME || name.indexOf("\u00b7") !== -1) return null;
18
+ // A separator or a drive colon means a shell wrote the path in; a name has neither.
19
+ if (name.indexOf("/") !== -1 || name.indexOf(":") !== -1) return null;
20
+ return DEFAULT_TITLES.indexOf(name.toLowerCase()) === -1 ? name : null;
21
+ }
22
+
23
+ // Identity precedence: origin remote URL > repo root path > window title > cwd.
24
+ // isRepo stays literal; hasColor is the question every caller actually asks.
25
+ function identityFrom({ gitCombined, remoteUrl, cwd, windowTitle }) {
26
+ if (!gitCombined) {
27
+ const named = usableWindowTitle(windowTitle);
28
+ // Namespaced so a tab called "aura" cannot land on the aura repo's color.
29
+ if (named) {
30
+ return { repoId: "window:" + named, branch: null, name: named, isRepo: false, hasColor: true, fromWindowTitle: true, root: null };
31
+ }
32
+ const normalized = path.resolve(cwd);
33
+ return { repoId: normalized, branch: null, name: path.basename(normalized), isRepo: false, hasColor: false, fromWindowTitle: false, root: null };
34
+ }
35
+ const lines = gitCombined.split(/\r?\n/);
36
+ const root = lines[0];
37
+ // "HEAD" means unborn (no commits yet) or detached: a repo, with no branch.
38
+ const branch = lines[1] && lines[1] !== "HEAD" ? lines[1] : null;
39
+ return { repoId: remoteUrl || root, branch, name: path.basename(root), isRepo: true, hasColor: true, fromWindowTitle: false, root };
40
+ }
41
+
42
+ // A name holds still; an agent's title follows the prompt. So a title becomes an
43
+ // identity only once two prompts have read it the same.
44
+ function settleWindowName(probe, found) {
45
+ if (found && probe === undefined) return { probe: found, name: null };
46
+ return { probe: null, name: found && found === probe ? found : "" };
47
+ }
48
+
49
+ // "prompt" is the shell caller's name for the same thing: the window is already
50
+ // up, so there is no TUI init race to wait out.
51
+ function isPromptEvent(eventName) {
52
+ return eventName === "UserPromptSubmit" || eventName === "prompt";
53
+ }
54
+
55
+ // Proof the session runs in a terminal the user launched; headless runs set none.
56
+ const TERMINAL_MARKERS = ["WT_SESSION", "WEZTERM_PANE", "ALACRITTY_WINDOW_ID", "GHOSTTY_RESOURCES_DIR"];
57
+
58
+ function hasTerminalMarker(env) {
59
+ return TERMINAL_MARKERS.some(function (name) { return Boolean(env[name]); });
60
+ }
61
+
62
+ // Tabs share one window frame, so ownership is a property of the window.
63
+ function coloredSessionHwnds(sessions, exceptSessionId) {
64
+ const hwnds = [];
65
+ Object.keys(sessions || {}).forEach(function (id) {
66
+ const session = sessions[id];
67
+ if (id !== exceptSessionId && session && session.hasColor === true && session.hwnd) {
68
+ const key = String(session.hwnd);
69
+ if (hwnds.indexOf(key) === -1) hwnds.push(key);
70
+ }
71
+ });
72
+ return hwnds;
73
+ }
74
+
75
+ function windowHasColoredSession(sessions, hwnd, exceptSessionId) {
76
+ if (!hwnd) return false;
77
+ return coloredSessionHwnds(sessions, exceptSessionId).indexOf(String(hwnd)) !== -1;
78
+ }
79
+
80
+ function decideEvent({ eventName, platform, session, frameHex, vtSignature, hasColor, windowFrameCleared }) {
81
+ const isPrompt = isPromptEvent(eventName);
82
+ // A session start may land in a new tab or window, so it re-handshakes.
83
+ const clearHandshake = !isPrompt;
84
+ const cachedVtSent = clearHandshake ? undefined : session.vtSent;
85
+ const needsVtDelivery = platform === "win32" && cachedVtSent !== vtSignature;
86
+ // The start-time window is a guess (the user may be looking elsewhere); the
87
+ // first prompt proves which window is theirs, and already spawns for VT.
88
+ const reresolveWindow = isPrompt && needsVtDelivery;
89
+ const cachedHwnd = (clearHandshake || reresolveWindow) ? null : session.hwnd || null;
90
+ // A bare shell in the window may have cleared the color this session owns.
91
+ const reclaimFrame = hasColor && Boolean(windowFrameCleared);
92
+ // With no identity the frame resets once, so a window aura colored earlier goes back.
93
+ const needsReset = !hasColor && !(clearHandshake ? false : session.frameCleared);
94
+ const needsFrame = !cachedHwnd || session.frameHex !== frameHex || reclaimFrame || needsReset;
95
+ return {
96
+ isPrompt,
97
+ clearHandshake,
98
+ cachedHwnd,
99
+ spawnAdapter: needsFrame || needsVtDelivery,
100
+ // An immediate start-time write races Claude Code's TUI init and is wiped.
101
+ vtDelayMs: isPrompt ? 0 : 2000,
102
+ markVtSent: isPrompt && needsVtDelivery,
103
+ // No repo and no tab name: the window keeps the terminal's own default.
104
+ paintsFrame: hasColor,
105
+ resetFrame: !hasColor,
106
+ usesColor: hasColor,
107
+ };
108
+ }
109
+
110
+ module.exports = {
111
+ identityFrom, usableWindowTitle, settleWindowName, isPromptEvent, decideEvent,
112
+ hasTerminalMarker, windowHasColoredSession, coloredSessionHwnds,
113
+ };
package/src/git.js ADDED
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ // The git probe. Lives outside hook.js so tests can drive it against real repos.
3
+ const { execFileSync } = require("child_process");
4
+ const { identityFrom } = require("./decide.js");
5
+
6
+ // The output decides, never the exit code: rev-parse exits 128 on a repo with
7
+ // no commits yet, but still prints a valid toplevel on stdout.
8
+ function runGit(cwd, args) {
9
+ try {
10
+ return execFileSync("git", ["-C", cwd].concat(args), {
11
+ timeout: 1500,
12
+ stdio: ["ignore", "pipe", "ignore"],
13
+ }).toString().trim() || null;
14
+ } catch (err) {
15
+ const partial = err && err.stdout ? err.stdout.toString().trim() : "";
16
+ return partial || null;
17
+ }
18
+ }
19
+
20
+ // One git spawn on the hot path; the remote URL is cached per repo root.
21
+ function resolveIdentity(cwd, state, recheckNullRemote, windowTitle) {
22
+ const combined = runGit(cwd, ["rev-parse", "--show-toplevel", "--abbrev-ref", "HEAD"]);
23
+ let remoteUrl = null;
24
+ if (combined) {
25
+ const root = combined.split(/\r?\n/)[0];
26
+ const remotes = state.remotes || (state.remotes = {});
27
+ // Recheck nulls at session start only: a repo that gains an origin remote
28
+ // must stop using its path color, but the prompt path stays at one spawn.
29
+ if (!(root in remotes) || (recheckNullRemote && remotes[root] === null)) {
30
+ remotes[root] = runGit(cwd, ["config", "--get", "remote.origin.url"]);
31
+ }
32
+ remoteUrl = remotes[root];
33
+ }
34
+ // The caller persists remoteUrl under root: without it every prompt pays a
35
+ // second git spawn to re-learn the same URL.
36
+ return Object.assign(identityFrom({ gitCombined: combined, remoteUrl, cwd, windowTitle }), { remoteUrl });
37
+ }
38
+
39
+ module.exports = { runGit, resolveIdentity };
package/src/hook.js ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // aura hook entry, fired by Claude Code on SessionStart and UserPromptSubmit.
4
+ // Never blocks a prompt (rule 6) and never writes escapes to stdout (rule 3),
5
+ // which is why the sink here is the tty device. Design: docs/ARCHITECTURE.md.
6
+ const fs = require("fs");
7
+ const { mark } = require("./mark.js");
8
+ const { writeToTerminal } = require("./tty.js");
9
+
10
+ function main() {
11
+ let raw = "";
12
+ try { raw = fs.readFileSync(0, "utf8"); } catch (err) { /* no stdin */ }
13
+ let event = {};
14
+ try { event = JSON.parse(raw); } catch (err) { /* not JSON */ }
15
+
16
+ mark({
17
+ cwd: event.cwd || process.cwd(),
18
+ sessionId: event.session_id || "unknown",
19
+ eventName: event.hook_event_name,
20
+ promptText: event.prompt,
21
+ sink: writeToTerminal,
22
+ // On Windows this write lands in the hook's own hidden console, so the
23
+ // adapter has to repeat the escapes into the tab's real console.
24
+ redeliverVt: true,
25
+ });
26
+ }
27
+
28
+ try { main(); } catch (err) { /* rule 6: fail silent */ }
29
+ process.exit(0);
package/src/install.js ADDED
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+ // Wires aura into the two things that can call it: Claude Code's hooks, and a
3
+ // shell profile. Rule 4: back up first, merge, NEVER overwrite. Flags:
4
+ // --settings <path>, --shell <name>, --profile <path>.
5
+ const fs = require("fs");
6
+ const os = require("os");
7
+ const path = require("path");
8
+ const { execFileSync } = require("child_process");
9
+ const { shellSnippet, SHELLS } = require("./shell/init.js");
10
+
11
+ function argValue(flag) {
12
+ const index = process.argv.indexOf(flag);
13
+ return index !== -1 && process.argv[index + 1] ? process.argv[index + 1] : null;
14
+ }
15
+
16
+ const SETTINGS_FILE = argValue("--settings") || path.join(os.homedir(), ".claude", "settings.json");
17
+ const HOOK_EVENTS = ["SessionStart", "UserPromptSubmit"];
18
+ const BLOCK_OPEN = "# >>> aura >>>";
19
+ const BLOCK_CLOSE = "# <<< aura <<<";
20
+
21
+ // Forward slashes work in every shell Claude Code uses to run hook commands.
22
+ const hookScript = path.resolve(__dirname, "hook.js").replace(/\\/g, "/");
23
+ const hookCommand = 'node "' + hookScript + '"';
24
+ const cli = path.resolve(__dirname, "..", "bin", "aura.js");
25
+
26
+ function backUpOnce(file, raw) {
27
+ // Only the first run writes the backup: it holds the pre-aura content.
28
+ if (!fs.existsSync(file + ".aura-bak")) fs.writeFileSync(file + ".aura-bak", raw);
29
+ }
30
+
31
+ function writeAtomic(file, contents) {
32
+ // A crash mid-write must never truncate the file we were asked to preserve.
33
+ const tmp = file + ".aura-tmp";
34
+ fs.writeFileSync(tmp, contents);
35
+ fs.renameSync(tmp, file);
36
+ }
37
+
38
+ function isAuraGroup(group) {
39
+ return Array.isArray(group.hooks) && group.hooks.some(function (h) {
40
+ return typeof h.command === "string" && h.command.indexOf("aura/src/hook.js") !== -1;
41
+ });
42
+ }
43
+
44
+ function installHooks(uninstall) {
45
+ if (!fs.existsSync(SETTINGS_FILE)) {
46
+ console.error("aura: " + SETTINGS_FILE + " not found. Is Claude Code installed?");
47
+ process.exit(1);
48
+ }
49
+ const raw = fs.readFileSync(SETTINGS_FILE, "utf8");
50
+ let settings;
51
+ try {
52
+ settings = JSON.parse(raw);
53
+ } catch (err) {
54
+ console.error("aura: " + SETTINGS_FILE + " is not valid JSON; refusing to touch it.");
55
+ process.exit(1);
56
+ }
57
+ backUpOnce(SETTINGS_FILE, raw);
58
+
59
+ if (!settings.hooks || typeof settings.hooks !== "object") settings.hooks = {};
60
+ let changed = false;
61
+ for (const eventName of HOOK_EVENTS) {
62
+ const groups = Array.isArray(settings.hooks[eventName]) ? settings.hooks[eventName] : [];
63
+ const hasAura = groups.some(isAuraGroup);
64
+ if (uninstall && hasAura) {
65
+ settings.hooks[eventName] = groups.filter(function (g) { return !isAuraGroup(g); });
66
+ changed = true;
67
+ } else if (!uninstall && !hasAura) {
68
+ groups.push({ hooks: [{ type: "command", command: hookCommand }] });
69
+ settings.hooks[eventName] = groups;
70
+ changed = true;
71
+ }
72
+ }
73
+
74
+ if (changed) writeAtomic(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n");
75
+ const action = uninstall ? "removed from" : "installed into";
76
+ console.log("aura: " + (changed ? action : "no change needed in") + " " + SETTINGS_FILE);
77
+ console.log("aura: backup at " + SETTINGS_FILE + ".aura-bak");
78
+ if (changed && !uninstall) {
79
+ console.log("aura: colors appear in NEW Claude Code sessions (existing sessions keep their old hook config).");
80
+ }
81
+ }
82
+
83
+ function powershellProfile() {
84
+ // PowerShell knows where its own profile is; Documents is often redirected.
85
+ const out = execFileSync("powershell.exe",
86
+ ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "$PROFILE"]).toString().trim();
87
+ if (!out) throw new Error("powershell.exe did not report a profile path");
88
+ return out;
89
+ }
90
+
91
+ function profileFor(shell) {
92
+ const explicit = argValue("--profile");
93
+ if (explicit) return explicit;
94
+ if (shell === "powershell") return powershellProfile();
95
+ if (shell === "zsh") return path.join(os.homedir(), ".zshrc");
96
+ return path.join(os.homedir(), ".bashrc");
97
+ }
98
+
99
+ function withoutAuraBlock(text) {
100
+ const open = text.indexOf(BLOCK_OPEN);
101
+ if (open === -1) return text;
102
+ const close = text.indexOf(BLOCK_CLOSE, open);
103
+ if (close === -1) return text;
104
+ const before = text.slice(0, open).replace(/\n+$/, "");
105
+ const after = text.slice(close + BLOCK_CLOSE.length).replace(/^\n+/, "");
106
+ if (!before) return after;
107
+ if (!after) return before + "\n";
108
+ return before + "\n\n" + after;
109
+ }
110
+
111
+ function installShell(shell, uninstall) {
112
+ let file;
113
+ try {
114
+ file = profileFor(shell);
115
+ } catch (err) {
116
+ console.error("aura: could not find the " + shell + " profile. Pass --profile <path>.");
117
+ process.exit(1);
118
+ }
119
+ const existed = fs.existsSync(file);
120
+ if (!existed && uninstall) {
121
+ console.log("aura: no change needed in " + file);
122
+ return;
123
+ }
124
+ const raw = existed ? fs.readFileSync(file, "utf8") : "";
125
+ if (existed) backUpOnce(file, raw);
126
+
127
+ const stripped = withoutAuraBlock(raw);
128
+ let next = stripped;
129
+ if (!uninstall) {
130
+ const snippet = shellSnippet(shell, cli);
131
+ const block = BLOCK_OPEN + "\n" + snippet.replace(/\n+$/, "") + "\n" + BLOCK_CLOSE + "\n";
132
+ next = stripped ? stripped.replace(/\n*$/, "\n\n") + block : block;
133
+ }
134
+ if (next === raw) {
135
+ console.log("aura: no change needed in " + file);
136
+ return;
137
+ }
138
+ fs.mkdirSync(path.dirname(file), { recursive: true });
139
+ writeAtomic(file, next);
140
+ console.log("aura: " + (uninstall ? "removed from " : "installed into ") + file);
141
+ if (existed) console.log("aura: backup at " + file + ".aura-bak");
142
+ if (!uninstall) console.log("aura: colors appear in NEW shells. Open a terminal in a repo to see it.");
143
+ }
144
+
145
+ function run(uninstall) {
146
+ const shell = argValue("--shell");
147
+ if (shell) {
148
+ if (SHELLS.indexOf(shell) === -1) {
149
+ console.error("aura: unknown shell " + shell + ". Known: " + SHELLS.join(", "));
150
+ process.exit(1);
151
+ }
152
+ installShell(shell, uninstall);
153
+ return;
154
+ }
155
+ installHooks(uninstall);
156
+ }
157
+
158
+ module.exports = { run };
package/src/mark.js ADDED
@@ -0,0 +1,214 @@
1
+ "use strict";
2
+ // The whole colouring operation, with no opinion about who called it. Callers
3
+ // differ only in where the escapes go and whether the adapter must repeat them.
4
+ // Rules and measurements: docs/ARCHITECTURE.md.
5
+ const path = require("path");
6
+ const { execFileSync } = require("child_process");
7
+ const { colorsFor, fnv1a } = require("./color.js");
8
+ const {
9
+ identityFrom, usableWindowTitle, settleWindowName, isPromptEvent, decideEvent,
10
+ hasTerminalMarker, windowHasColoredSession, coloredSessionHwnds,
11
+ } = require("./decide.js");
12
+ const { resolveIdentity } = require("./git.js");
13
+ const { readState, updateState, stateFile } = require("./state.js");
14
+
15
+ const ESC = "\u001b";
16
+ const BEL = "\u0007";
17
+ const PROMPT_SNIPPET_LEN = 60;
18
+ // Palette slot redefined to carry the tab RGB, then selected with DECAC. It sits
19
+ // above 255 so aura never repaints an index text can be printed in.
20
+ const TAB_COLOR_SLOT = 264;
21
+
22
+ // Prompt text lands inside an escape sequence: strip control bytes so it can
23
+ // never terminate or inject a sequence of its own.
24
+ function sanitizeForTitle(text) {
25
+ return String(text).replace(/[\u0000-\u001f]+/g, " ").replace(/\s+/g, " ").trim();
26
+ }
27
+
28
+ function buildEscapes(colors, title, usesColor, env) {
29
+ if (!usesColor) return title ? `${ESC}]0;${title}${BEL}` : "";
30
+ let out = `${ESC}]11;${colors.tintHex}${BEL}`;
31
+ const hex = colors.frameHex;
32
+ if (env.WT_SESSION) {
33
+ const r = hex.slice(1, 3);
34
+ const g = hex.slice(3, 5);
35
+ const b = hex.slice(5, 7);
36
+ out += `${ESC}]4;${TAB_COLOR_SLOT};rgb:${r}/${g}/${b}${BEL}`;
37
+ out += `${ESC}[2;15;${TAB_COLOR_SLOT},|`;
38
+ } else if (env.TERM_PROGRAM === "iTerm.app") {
39
+ const r = parseInt(hex.slice(1, 3), 16);
40
+ const g = parseInt(hex.slice(3, 5), 16);
41
+ const b = parseInt(hex.slice(5, 7), 16);
42
+ out += `${ESC}]6;1;bg;red;brightness;${r}${BEL}`;
43
+ out += `${ESC}]6;1;bg;green;brightness;${g}${BEL}`;
44
+ out += `${ESC}]6;1;bg;blue;brightness;${b}${BEL}`;
45
+ }
46
+ if (title) out += `${ESC}]0;${title}${BEL}`;
47
+ return out;
48
+ }
49
+
50
+ // The tab's own name, read through the same allowlist the frame paint uses. The
51
+ // cached handle is preferred: the foreground window may belong to another tab.
52
+ function queryWindowTitle(env, cachedHwnd) {
53
+ if (process.platform !== "win32") return null;
54
+ if (!cachedHwnd && !hasTerminalMarker(env)) return null;
55
+ const adapter = path.join(__dirname, "adapters", "frame-win.ps1");
56
+ const args = [
57
+ "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", adapter,
58
+ "-FrameColor", "000000", "-QueryTitle",
59
+ ];
60
+ if (cachedHwnd) args.push("-Hwnd", String(cachedHwnd));
61
+ try {
62
+ return execFileSync("powershell.exe", args, {
63
+ timeout: 5000,
64
+ stdio: ["ignore", "pipe", "ignore"],
65
+ windowsHide: true,
66
+ }).toString().trim() || null;
67
+ } catch (err) {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function paintFrame(frameHex, cachedHwnd, vtPayload, vtDelay, mode, env) {
73
+ if (process.platform !== "win32") return null;
74
+ // Without a terminal marker this is a headless run, where the foreground
75
+ // window belongs to some unrelated app. A cached handle is always safe.
76
+ if (!cachedHwnd && !hasTerminalMarker(env)) return null;
77
+ const adapter = path.join(__dirname, "adapters", "frame-win.ps1");
78
+ const args = [
79
+ "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", adapter,
80
+ "-FrameColor", frameHex.slice(1),
81
+ ];
82
+ if (cachedHwnd) args.push("-Hwnd", String(cachedHwnd));
83
+ // Resolve the handle without writing a color a repo sibling may own.
84
+ if (mode.name !== "paint") args.push("-NoPaint");
85
+ if (mode.name === "reset") {
86
+ args.push("-Reset");
87
+ if (mode.skipHwnds.length) args.push("-SkipHwnds", mode.skipHwnds.join(","));
88
+ }
89
+ if (vtPayload) {
90
+ args.push("-VtB64", Buffer.from(vtPayload, "utf8").toString("base64"));
91
+ if (vtDelay) {
92
+ args.push("-VtDelayMs", String(vtDelay.ms));
93
+ args.push("-StateFile", stateFile());
94
+ args.push("-SessionId", vtDelay.sessionId);
95
+ args.push("-VtSig", vtDelay.sig);
96
+ }
97
+ }
98
+ try {
99
+ const out = execFileSync("powershell.exe", args, {
100
+ timeout: 5000,
101
+ stdio: ["ignore", "pipe", "ignore"],
102
+ windowsHide: true,
103
+ }).toString().trim();
104
+ const hwnd = parseInt(out, 10);
105
+ return Number.isFinite(hwnd) && hwnd > 0 ? hwnd : null;
106
+ } catch (err) {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ // sink writes the escapes and returns a label for state. redeliverVt: the
112
+ // caller's console is hidden, so the adapter repeats them into the real one.
113
+ function mark({
114
+ cwd, sessionId, eventName, promptText, env = process.env,
115
+ sink = () => null, redeliverVt = true,
116
+ }) {
117
+ // Only a snapshot for identity and the decision; nothing here reaches disk.
118
+ const state = readState() || { sessions: {} };
119
+ // A tag outranks cwd, which carries no project when an agent was launched
120
+ // from a home folder.
121
+ const pinned = (state.tags || {})[sessionId];
122
+ const session = state.sessions[sessionId] || {};
123
+ // A tag is an explicit answer, so a tagged session never reads its tab name.
124
+ const windowName = pinned ? null : session.windowName || null;
125
+ let identity = resolveIdentity(pinned || cwd, state, eventName === "SessionStart", windowName);
126
+ if (!identity.hasColor && !pinned && session.windowName === undefined && isPromptEvent(eventName)) {
127
+ const found = usableWindowTitle(queryWindowTitle(env, session.hwnd));
128
+ const settled = settleWindowName(session.windowProbe, found);
129
+ if (settled.probe) session.windowProbe = settled.probe;
130
+ else {
131
+ delete session.windowProbe;
132
+ session.windowName = settled.name;
133
+ }
134
+ // git already answered null here, so the name settles it with no second spawn.
135
+ if (session.windowName) identity = identityFrom({ gitCombined: null, cwd, windowTitle: session.windowName });
136
+ }
137
+ const colors = colorsFor({ repoId: identity.repoId, branch: identity.branch });
138
+
139
+ const titleParts = [identity.name];
140
+ if (identity.branch) titleParts.push(identity.branch);
141
+ if (promptText) titleParts.push(sanitizeForTitle(promptText).slice(0, PROMPT_SNIPPET_LEN));
142
+ // Writing a title over an identity we READ from that title renames it, and
143
+ // the rename would move the color on the next prompt.
144
+ const title = identity.fromWindowTitle ? "" : titleParts.join(" · ");
145
+ const escapes = buildEscapes(colors, title, identity.hasColor, env);
146
+ // The signature covers what was delivered, not just its color, so any change
147
+ // to the escapes re-delivers. The title is out: it moves every prompt.
148
+ const vtSignature = fnv1a(buildEscapes(colors, "", identity.hasColor, env)).toString(36);
149
+
150
+ // A caller that writes to a visible console has already delivered them, so
151
+ // caching here is what keeps the adapter off that caller's prompt path.
152
+ if (!redeliverVt) session.vtSent = vtSignature;
153
+ session.tty = sink(escapes);
154
+
155
+ const owners = state.frameOwner || {};
156
+ const plan = decideEvent({
157
+ eventName,
158
+ platform: process.platform,
159
+ session,
160
+ frameHex: colors.frameHex,
161
+ vtSignature,
162
+ hasColor: identity.hasColor,
163
+ windowFrameCleared: Boolean(session.hwnd && owners[String(session.hwnd)] === "cleared"),
164
+ });
165
+ if (plan.clearHandshake) {
166
+ delete session.hwnd;
167
+ delete session.vtSent;
168
+ delete session.frameCleared;
169
+ }
170
+ let painted = false;
171
+ let cleared = false;
172
+ if (plan.spawnAdapter) {
173
+ const vtDelay = plan.vtDelayMs > 0 ? { ms: plan.vtDelayMs, sessionId, sig: vtSignature } : null;
174
+ // The adapter re-checks the list: the window it resolves may not be cached yet.
175
+ const mode = plan.paintsFrame ? { name: "paint" }
176
+ : plan.resetFrame ? { name: "reset", skipHwnds: coloredSessionHwnds(state.sessions, sessionId) }
177
+ : { name: "none" };
178
+ const payload = redeliverVt ? escapes : null;
179
+ const hwnd = paintFrame(colors.frameHex, plan.cachedHwnd, payload, vtDelay, mode, env);
180
+ if (hwnd) {
181
+ session.hwnd = hwnd;
182
+ // Ownership follows the color write, not the handle lookup.
183
+ painted = plan.paintsFrame;
184
+ cleared = mode.name === "reset" &&
185
+ !windowHasColoredSession(state.sessions, hwnd, sessionId);
186
+ if (cleared) session.frameCleared = true;
187
+ if (plan.markVtSent && redeliverVt) session.vtSent = vtSignature;
188
+ }
189
+ }
190
+ session.repoId = identity.repoId;
191
+ session.branch = identity.branch;
192
+ // repoId is a path either way, so it cannot tell a colored session from a bare one.
193
+ session.isRepo = identity.isRepo;
194
+ session.hasColor = identity.hasColor;
195
+ session.frameHex = colors.frameHex;
196
+ if (promptText) session.lastPrompt = sanitizeForTitle(promptText).slice(0, 200);
197
+ session.updatedAt = new Date().toISOString();
198
+ // Only this session's entry is ours to write; the rest of the file belongs
199
+ // to concurrent shells, so the delta goes onto a fresh read under a lock.
200
+ updateState(function (fresh) {
201
+ fresh.sessions[sessionId] = session;
202
+ if (identity.root) (fresh.remotes || (fresh.remotes = {}))[identity.root] = identity.remoteUrl;
203
+ // Ownership is keyed by HWND, because tabs share one frame.
204
+ if (session.hwnd && process.platform === "win32") {
205
+ const freshOwners = fresh.frameOwner || (fresh.frameOwner = {});
206
+ const hwndKey = String(session.hwnd);
207
+ if (painted) freshOwners[hwndKey] = sessionId;
208
+ else if (cleared) freshOwners[hwndKey] = "cleared";
209
+ }
210
+ });
211
+ return { identity, colors, escapes, hwnd: session.hwnd || null };
212
+ }
213
+
214
+ module.exports = { mark, buildEscapes, sanitizeForTitle };
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ // The snippets next to this file are data with one hole in them: the path of
3
+ // the CLI the shell should call. Both callers know it from their own location.
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+
7
+ const SNIPPETS = { powershell: "powershell.ps1", bash: "posix.sh", zsh: "posix.sh" };
8
+
9
+ function shellSnippet(shell, cliPath) {
10
+ if (!SNIPPETS[shell]) return null;
11
+ const text = fs.readFileSync(path.join(__dirname, SNIPPETS[shell]), "utf8");
12
+ // Forward slashes work in every shell these snippets target.
13
+ return text.replace(/__AURA_CLI__/g, cliPath.replace(/\\/g, "/"));
14
+ }
15
+
16
+ module.exports = { shellSnippet, SHELLS: Object.keys(SNIPPETS) };
@@ -0,0 +1,33 @@
1
+ # Appends to the prompt hook instead of replacing it, so an existing
2
+ # PROMPT_COMMAND or precmd keeps running. Emitted by "aura shell-init".
3
+ AURA_CLI="__AURA_CLI__"
4
+
5
+ # Start second, because pids are recycled well inside the 48h state window.
6
+ if [ -z "$AURA_SESSION" ]; then
7
+ AURA_SESSION="shell-$$-$(date +%s)"
8
+ fi
9
+ # Exported so an agent started here tags the window's own session, not its pid.
10
+ export AURA_SESSION
11
+ AURA_LAST_PATH=""
12
+
13
+ aura_mark_cwd() {
14
+ # Rule 6: a broken aura must never break a prompt.
15
+ [ "$PWD" = "$AURA_LAST_PATH" ] && return 0
16
+ AURA_LAST_PATH="$PWD"
17
+ aura_out=$(command node "$AURA_CLI" mark --write --cwd "$PWD" --session "$AURA_SESSION" 2>/dev/null)
18
+ [ -n "$aura_out" ] && printf '%s' "$aura_out"
19
+ return 0
20
+ }
21
+
22
+ if [ -n "$ZSH_VERSION" ]; then
23
+ case " ${precmd_functions[*]} " in
24
+ *" aura_mark_cwd "*) ;;
25
+ *) precmd_functions+=(aura_mark_cwd) ;;
26
+ esac
27
+ elif [ -n "$BASH_VERSION" ]; then
28
+ case "$PROMPT_COMMAND" in
29
+ *aura_mark_cwd*) ;;
30
+ "") PROMPT_COMMAND="aura_mark_cwd" ;;
31
+ *) PROMPT_COMMAND="aura_mark_cwd;$PROMPT_COMMAND" ;;
32
+ esac
33
+ fi
@@ -0,0 +1,33 @@
1
+ # Wraps the current prompt instead of replacing it, so posh-git, oh-my-posh and
2
+ # Starship keep working. Emitted by "aura shell-init", which fills the CLI path.
3
+ $global:AuraCli = "__AURA_CLI__"
4
+
5
+ # Start second, because Windows recycles pids well inside the 48h state window.
6
+ if (-not $global:AuraSession) {
7
+ if ($env:AURA_SESSION) {
8
+ $global:AuraSession = $env:AURA_SESSION
9
+ } else {
10
+ $global:AuraSession = "shell-" + $PID + "-" + [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
11
+ }
12
+ }
13
+ # Exported so an agent started here tags the window's own session, not its pid.
14
+ $env:AURA_SESSION = $global:AuraSession
15
+
16
+ # Re-wrap when something else took the prompt; a plain re-source is a no-op.
17
+ if ($null -eq $function:prompt -or $function:prompt.ToString() -notmatch 'aura-prompt') {
18
+ $global:AuraPrevPrompt = $function:prompt
19
+ $global:AuraLastPath = ""
20
+ }
21
+
22
+ function global:prompt {
23
+ # aura-prompt
24
+ try {
25
+ $auraPath = $PWD.ProviderPath
26
+ if ($auraPath -ne $global:AuraLastPath) {
27
+ $global:AuraLastPath = $auraPath
28
+ $auraOut = & node $global:AuraCli mark --write --cwd $auraPath --session $global:AuraSession
29
+ if ($auraOut) { [Console]::Write(($auraOut -join "")) }
30
+ }
31
+ } catch { }
32
+ if ($global:AuraPrevPrompt) { & $global:AuraPrevPrompt } else { "PS " + $PWD.ProviderPath + "> " }
33
+ }