@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kitfunso
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # aura
2
+
3
+ [![npm](https://img.shields.io/npm/v/%40kitfunso%2Faura)](https://www.npmjs.com/package/@kitfunso/aura)
4
+ [![license: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
5
+ ![platform: Windows 11](https://img.shields.io/badge/platform-Windows%2011-blue)
6
+ ![runtime dependencies: 0](https://img.shields.io/badge/runtime%20deps-0-brightgreen)
7
+
8
+ Color identity for terminal windows. Repo = hue, branch = shade. Any shell
9
+ sitting in a repo gets the color, so Claude Code, Codex, Aider, opencode and a
10
+ plain git prompt all get one from the same place. With 10+ windows open, you
11
+ find the one you want by color, not by reading.
12
+
13
+ ![Demo: gray terminal windows get per-repo colors, branches get shades, and aura-overlay rings any window](https://raw.githubusercontent.com/kitfunso/aura/master/assets/demo.gif)
14
+
15
+ ```
16
+ npx @kitfunso/aura install --shell powershell
17
+ ```
18
+
19
+ ![Live QA: pink tab + tinted pane + pink border for the bitfall session, blue tab for the aura session](https://raw.githubusercontent.com/kitfunso/aura/master/docs/img/live-qa.png)
20
+
21
+ One Windows Terminal window, three Claude Code sessions: the active bitfall
22
+ session got a pink tab, a pink-tinted pane, and a pink window border; the aura
23
+ session's tab is blue. Nothing was configured by hand.
24
+
25
+ ## What it does
26
+
27
+ When a window enters a repo, aura:
28
+
29
+ 1. **Colors the tab** (Windows Terminal, iTerm2) with the repo's color. In
30
+ tabbed windows this is the primary identity surface.
31
+ 2. **Tints the pane background** with a dark shade of the same color (OSC 11).
32
+ 3. **Paints the real window frame** (border + title bar) via the Windows 11
33
+ DWM API. In stock tabbed Windows Terminal only a 1 px border shows (WT
34
+ draws its own tab strip over the title bar); on floating windows and
35
+ conhost-style terminals the full caption shows.
36
+ 4. **Sets the title** to `repo · branch` (best-effort; Claude Code itself
37
+ keeps the latest prompt in the title, which covers "what am I doing here").
38
+ A session colored by its own tab name is the exception: aura writes no title
39
+ there, so the name it read stays put.
40
+
41
+ Three callers, one core. Your shell calls `aura mark` from its prompt when the
42
+ directory changed. Claude Code calls the same core from a hook, which colors a
43
+ session the moment it starts. Any script can call `aura mark --cwd <dir>` when
44
+ it knows a window moved.
45
+
46
+ Colors are deterministic: the same repo maps to the same hue on every machine,
47
+ every restart. Branches get discrete shades of the repo hue; main/master is
48
+ the base shade. With no repo, no tag and no tab name there is no color: the
49
+ window keeps your terminal's own default.
50
+
51
+ So a color means the window is in that repo, or wears that name. It does not
52
+ mean an agent is running in it, and that is the trade for one mechanism every
53
+ tool gets for free.
54
+
55
+ ## When the folder is not the project
56
+
57
+ Agents get launched from a home folder as often as from a checkout, and a home
58
+ folder names no project. Six tabs on six projects then share one path that
59
+ names none of them. So identity has two more sources.
60
+
61
+ **Your tab name.** Rename a Windows Terminal tab and aura colors it by that
62
+ name, deterministically, the same way it colors a repo. Nothing to run. Tabs you
63
+ have not renamed are left alone, because aura only takes a title that behaves
64
+ like a name: under 40 characters, no path in it, not a shell's own default, and
65
+ unchanged across two prompts. An agent's title moves with every prompt, so it
66
+ never qualifies. A session colored by its tab name keeps that name, because
67
+ writing a new title would change the color it was just read from.
68
+
69
+ **A tag**, when you want a session to wear a specific repo's color:
70
+
71
+ ```
72
+ aura tag ~/hippo # this session is hippo, wherever it sits
73
+ aura tag # print the current tag
74
+ aura tag --clear # back to the working directory
75
+ ```
76
+
77
+ A tag outranks both the working directory and the tab name. Run it from inside
78
+ an agent and it tags that agent's own session. The key comes
79
+ from `CLAUDE_CODE_SESSION_ID`, or from `AURA_SESSION`, which the shell snippet
80
+ exports to every process the window starts. The color lands at once, through
81
+ the same path a hook paints on, so an agent with no prompt hook still gets one.
82
+ A tag lives and dies with its session, so nothing outlives the window that set it.
83
+
84
+ ## Install
85
+
86
+ ```
87
+ npx @kitfunso/aura install --shell powershell
88
+ ```
89
+
90
+ That wraps your PowerShell prompt, so every window in a repo gets the color
91
+ whatever is running inside it. `--shell bash` and `--shell zsh` write the same
92
+ thing into `~/.bashrc` / `~/.zshrc`. The snippet wraps your existing prompt
93
+ instead of replacing it, so posh-git, oh-my-posh and Starship keep working, and
94
+ it lands between two markers so a re-run replaces it instead of stacking.
95
+
96
+ Claude Code also has a native hook, which colors a session the moment it starts
97
+ rather than at its next prompt:
98
+
99
+ ```
100
+ npx @kitfunso/aura install
101
+ ```
102
+
103
+ That merges two hook entries (SessionStart, UserPromptSubmit) into
104
+ `~/.claude/settings.json`. Both installs together are fine: the shell path does
105
+ nothing when the directory has not changed. From a checkout, swap
106
+ `npx @kitfunso/aura` for `node bin/aura.js`.
107
+
108
+ On the first install the target file is copied to `<file>.aura-bak`. That backup
109
+ holds your pre-aura content and later runs never overwrite it. Uninstall takes
110
+ back out exactly what install put in:
111
+
112
+ ```
113
+ npx @kitfunso/aura uninstall --shell powershell
114
+ npx @kitfunso/aura uninstall
115
+ ```
116
+
117
+ Colors appear in new shells and new Claude Code sessions. Point either install
118
+ at a different file with `--profile <path>` or `--settings <path>`; the backup
119
+ lands next to that file.
120
+
121
+ Requirements: Windows 11 build 22000+ for the frame color, Windows Terminal
122
+ 1.15+ for the tab color, Node.js. Tint + title degrade gracefully elsewhere.
123
+ No runtime dependencies, no network calls, everything local.
124
+
125
+ The frame paint fires in terminals that mark their environment: Windows
126
+ Terminal (verified), wezterm, alacritty, and ghostty (per their docs,
127
+ best-effort). Plain conhost sets no marker and gets no paint.
128
+
129
+ ## How it works (and the traps we measured)
130
+
131
+ The design is shaped by four findings, all measured live on 2026-08-30
132
+ (details in `docs/ARCHITECTURE.md` Known Risks):
133
+
134
+ - **Claude Code hooks get their own hidden console on Windows.** A hook's
135
+ `CONOUT$` write succeeds but is invisible. Visible delivery attaches to the
136
+ topmost console-attached ancestor (the tab's real console) from the
137
+ PowerShell adapter, once per session or color change. The per-prompt path
138
+ spawns nothing and stays at ~70 ms.
139
+ - **Windows Terminal runs every window in one process**, so PID matching
140
+ cannot identify a window. The frame paint takes `GetForegroundWindow()` at
141
+ prompt time, allowlisted to terminal processes, and caches the HWND.
142
+ - **Claude Code rewrites the terminal title continuously**, so a
143
+ nonce-title handshake is impossible and aura's title is best-effort.
144
+ - **Tab color needs the DECAC escape** (`OSC 4;264;rgb:RR/GG/BB` +
145
+ `ESC[2;15;264,|`). Slot 264 sits above the 256-color text palette, so your
146
+ text keeps its own colors; aura only moves the background and the frame.
147
+
148
+ The shell path dodges the first of those: a shell prompt owns a visible
149
+ console, so `aura mark --write` puts the escapes there itself and needs no
150
+ PowerShell hop for them. The frame paint still spawns the adapter, once per
151
+ window and color.
152
+
153
+ Repo layout: `src/color.js` (the pure color contract), `src/mark.js` (the core
154
+ every caller goes through), `src/tag.js` (the session tag), `src/hook.js` and
155
+ `bin/aura.js` (the two callers),
156
+ `src/shell/` (the prompt snippets), `src/tty.js` (terminal device),
157
+ `src/adapters/frame-win.ps1` (all Win32 code), `src/install.js` (installer).
158
+ Tests, from the repo root:
159
+
160
+ ```
161
+ npm test
162
+ ```
163
+
164
+ ## Cross-platform
165
+
166
+ The core is OS-neutral; only the tty device path and the frame adapter vary.
167
+ macOS/Linux get tint + title (and iTerm2 tab color) from the same code today;
168
+ frame adapters for them are planned (macOS needs an overlay window, there is
169
+ no API to recolor another app's frame). See the support matrix in
170
+ `docs/ARCHITECTURE.md`.
171
+
172
+ ## Lane B (built)
173
+
174
+ The cross-app overlay lives at
175
+ [aura-overlay](https://github.com/kitfunso/aura-overlay): click-through
176
+ colored rings around any window, same repo = hue contract, hotkey tagging for
177
+ non-terminal windows. `src/color.js` is byte-identical in both repos; changes
178
+ flow from here to there, never back. This repo only guarantees the contract
179
+ stays pure. Spike evidence that green-lit it: `docs/LANE-B.md`.
package/bin/aura.js ADDED
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // The agent-neutral entry point: whoever knows the working directory changed
4
+ // calls "aura mark". Unlike the hook it may print escapes on stdout, because
5
+ // its caller owns a visible console. Design: docs/ARCHITECTURE.md.
6
+ const { mark } = require("../src/mark.js");
7
+ const { writeToTerminal } = require("../src/tty.js");
8
+ const { shellSnippet, SHELLS } = require("../src/shell/init.js");
9
+ const {
10
+ sessionKey, readTag, writeTag, resolveTarget, inTerminalSession,
11
+ } = require("../src/tag.js");
12
+
13
+ const USAGE = [
14
+ "usage: aura mark [--write] [--cwd <dir>] [--session <id>] [--title <text>]",
15
+ " aura tag [<dir>] [--clear] [--session <id>]",
16
+ " aura install [--shell powershell|bash|zsh] [--settings <path>] [--profile <path>]",
17
+ " aura uninstall [same flags as install]",
18
+ " aura shell-init [--shell powershell|bash|zsh]",
19
+ "",
20
+ " mark print the escape sequences for <dir>, and paint the window frame.",
21
+ " --write sends them to the terminal device instead, falling back",
22
+ " to stdout when that device is not reachable.",
23
+ " tag color this session as <dir> instead of the working directory,",
24
+ " for an agent launched somewhere that names no project. It paints",
25
+ " the window at once, so an agent with no prompt hook still gets a",
26
+ " color. With no argument it prints the tag. --clear goes to cwd.",
27
+ " install with --shell, wire that shell's prompt. Without it, register the",
28
+ " Claude Code hooks in ~/.claude/settings.json.",
29
+ " uninstall take back out what install put in.",
30
+ " shell-init print the prompt snippet to source from a shell profile.",
31
+ ].join("\n");
32
+
33
+ function argValue(flag) {
34
+ const index = process.argv.indexOf(flag);
35
+ return index !== -1 && process.argv[index + 1] ? process.argv[index + 1] : null;
36
+ }
37
+
38
+ function commandMark() {
39
+ const toTerminal = process.argv.indexOf("--write") !== -1;
40
+ let ttyTarget = null;
41
+ const result = mark({
42
+ cwd: argValue("--cwd") || process.cwd(),
43
+ // The pid alone is not unique for long: the shell snippets add a start second.
44
+ sessionId: argValue("--session") || "shell-" + process.ppid,
45
+ eventName: "prompt",
46
+ promptText: argValue("--title"),
47
+ sink: function (escapes) {
48
+ if (toTerminal) ttyTarget = writeToTerminal(escapes);
49
+ return ttyTarget || "stdout";
50
+ },
51
+ redeliverVt: false,
52
+ });
53
+ if (!ttyTarget) process.stdout.write(result.escapes);
54
+ }
55
+
56
+ function commandShellInit() {
57
+ const shell = argValue("--shell") || (process.platform === "win32" ? "powershell" : "bash");
58
+ const snippet = shellSnippet(shell, __filename);
59
+ if (!snippet) {
60
+ console.error("aura: unknown shell " + shell + ". Known: " + SHELLS.join(", "));
61
+ process.exit(1);
62
+ }
63
+ process.stdout.write(snippet);
64
+ }
65
+
66
+ // A full-screen agent has no prompt to fire after this, so the tag would sit in
67
+ // state unread. The escapes ride the adapter, the way a hook's do.
68
+ function paintTagged(sessionId) {
69
+ if (!inTerminalSession(process.env)) return;
70
+ try {
71
+ mark({
72
+ cwd: process.cwd(),
73
+ sessionId,
74
+ eventName: "prompt",
75
+ sink: writeToTerminal,
76
+ redeliverVt: true,
77
+ });
78
+ } catch (err) { /* the tag is written; a failed paint must not fail the CLI */ }
79
+ }
80
+
81
+ function commandTag() {
82
+ const id = sessionKey(process.env, argValue("--session"));
83
+ const clear = process.argv.indexOf("--clear") !== -1;
84
+ const target = process.argv[3] && process.argv[3].charAt(0) !== "-" ? process.argv[3] : null;
85
+ if (!target && !clear) {
86
+ const current = readTag(id);
87
+ console.log(id + ": " + (current || "no tag, using the working directory"));
88
+ return 0;
89
+ }
90
+ const resolved = clear ? null : resolveTarget(target, process.cwd());
91
+ if (!clear && !resolved) {
92
+ console.error("aura: no such directory: " + target);
93
+ return 1;
94
+ }
95
+ if (!writeTag(id, resolved)) {
96
+ console.error("aura: state file is busy, nothing written");
97
+ return 1;
98
+ }
99
+ paintTagged(id);
100
+ console.log(clear ? id + ": tag cleared" : id + ": tagged " + resolved);
101
+ return 0;
102
+ }
103
+
104
+ const command = process.argv[2];
105
+ if (command === "mark") {
106
+ // Rule 6: this runs on a prompt path, so a broken aura prints nothing.
107
+ try { commandMark(); } catch (err) { /* fail silent */ }
108
+ process.exit(0);
109
+ }
110
+ if (command === "tag") {
111
+ process.exit(commandTag());
112
+ }
113
+ if (command === "shell-init") {
114
+ commandShellInit();
115
+ process.exit(0);
116
+ }
117
+ if (command === "install" || command === "uninstall") {
118
+ require("../src/install.js").run(command === "uninstall");
119
+ process.exit(0);
120
+ }
121
+ console.error(USAGE);
122
+ process.exit(command === "--help" || command === "-h" ? 0 : 1);
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@kitfunso/aura",
3
+ "version": "0.1.0",
4
+ "description": "Color identity for Claude Code terminal windows: repo = hue, branch = shade, latest prompt in the title.",
5
+ "license": "MIT",
6
+ "author": "kitfunso",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/kitfunso/aura.git"
10
+ },
11
+ "homepage": "https://github.com/kitfunso/aura#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/kitfunso/aura/issues"
14
+ },
15
+ "bin": {
16
+ "aura": "bin/aura.js"
17
+ },
18
+ "files": [
19
+ "src/",
20
+ "bin/",
21
+ "README.md",
22
+ "LICENSE"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "test": "node scripts/check-comments.js && node --test"
29
+ },
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
33
+ "keywords": [
34
+ "claude-code",
35
+ "terminal",
36
+ "colors",
37
+ "windows-terminal",
38
+ "hooks"
39
+ ]
40
+ }
@@ -0,0 +1,164 @@
1
+ # aura frame adapter, Windows 11 build 22000+. Paints one window frame and
2
+ # delivers the VT payload into the tab's real console. Design: docs/ARCHITECTURE.md.
3
+ # Prints the resolved HWND on stdout first, or 0.
4
+ param(
5
+ [Parameter(Mandatory=$true)][string]$FrameColor, # RRGGBB, no leading #
6
+ [long]$Hwnd = 0,
7
+ [switch]$NoPaint, # resolve and report the HWND, but write no color
8
+ [switch]$QueryTitle, # print the terminal window title and exit; writes nothing
9
+ [switch]$Reset, # write DWMWA_COLOR_DEFAULT: back to the system frame
10
+ [string]$SkipHwnds = "", # -Reset only: comma-joined HWNDs a repo session owns
11
+ [string]$VtB64 = "",
12
+ [int]$VtDelayMs = 0, # >0: hand targets to a delayed hidden writer instead of writing now
13
+ [string]$VtTargets = "", # delayed-writer mode: comma-joined attach-target PIDs, topmost first
14
+ [string]$StateFile = "", # delayed-writer mode: state.json path for the delivery skip check
15
+ [string]$SessionId = "", # delayed-writer mode: which session to check
16
+ [string]$VtSig = "" # delayed-writer mode: signature a prompt would have recorded
17
+ )
18
+ $ErrorActionPreference = "Stop"
19
+
20
+ Add-Type -TypeDefinition @"
21
+ using System;
22
+ using System.Runtime.InteropServices;
23
+
24
+ public static class AuraFrame {
25
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
26
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
27
+ [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr h, System.Text.StringBuilder buf, int max);
28
+ [DllImport("dwmapi.dll")] public static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int val, int size);
29
+ [DllImport("kernel32.dll")] static extern bool FreeConsole();
30
+ [DllImport("kernel32.dll")] static extern bool AttachConsole(uint pid);
31
+ [DllImport("kernel32.dll", CharSet=CharSet.Unicode)] static extern IntPtr CreateFileW(string name, uint access, uint share, IntPtr sec, uint disp, uint flags, IntPtr tmpl);
32
+ [DllImport("kernel32.dll")] static extern bool WriteFile(IntPtr h, byte[] buf, uint n, out uint written, IntPtr overlapped);
33
+ [DllImport("kernel32.dll")] static extern bool GetConsoleMode(IntPtr h, out uint mode);
34
+ [DllImport("kernel32.dll")] static extern bool SetConsoleMode(IntPtr h, uint mode);
35
+ [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h);
36
+
37
+ public static bool WriteVt(uint pid, byte[] bytes) {
38
+ FreeConsole();
39
+ if (!AttachConsole(pid)) { return false; }
40
+ IntPtr h = CreateFileW("CONOUT$", 0xC0000000, 3, IntPtr.Zero, 3, 0, IntPtr.Zero);
41
+ if (h == new IntPtr(-1)) { FreeConsole(); return false; }
42
+ uint mode;
43
+ if (GetConsoleMode(h, out mode)) { SetConsoleMode(h, mode | 0x4); }
44
+ uint written;
45
+ bool ok = WriteFile(h, bytes, (uint)bytes.Length, out written, IntPtr.Zero);
46
+ CloseHandle(h);
47
+ FreeConsole();
48
+ return ok;
49
+ }
50
+ }
51
+ "@
52
+
53
+ if ($VtTargets -ne "") {
54
+ # Delayed writer: the parent resolved the targets while the ancestor chain
55
+ # was alive. Skip if a prompt already delivered a newer payload.
56
+ if ($VtDelayMs -gt 0) { Start-Sleep -Milliseconds $VtDelayMs }
57
+ if ($StateFile -ne "" -and $SessionId -ne "" -and (Test-Path -LiteralPath $StateFile)) {
58
+ try {
59
+ $state = Get-Content -Raw -LiteralPath $StateFile | ConvertFrom-Json
60
+ $sess = $state.sessions.$SessionId
61
+ if ($sess -and $VtSig -ne "" -and $sess.vtSent -eq $VtSig) { exit 0 }
62
+ } catch {}
63
+ }
64
+ try {
65
+ $bytes = [Convert]::FromBase64String($VtB64)
66
+ foreach ($target in ($VtTargets -split ",")) {
67
+ if ([AuraFrame]::WriteVt([uint32]$target, $bytes)) { break }
68
+ }
69
+ } catch {}
70
+ exit 0
71
+ }
72
+
73
+ $allowedProcs = @("WindowsTerminal", "OpenConsole", "conhost", "wezterm-gui", "alacritty", "ghostty")
74
+
75
+ function Resolve-TerminalWindow {
76
+ $fg = [AuraFrame]::GetForegroundWindow()
77
+ $procId = [uint32]0
78
+ [void][AuraFrame]::GetWindowThreadProcessId($fg, [ref]$procId)
79
+ $procName = ""
80
+ try { $procName = (Get-Process -Id $procId -ErrorAction Stop).ProcessName } catch {}
81
+ if ($allowedProcs -contains $procName) { return $fg }
82
+ return [IntPtr]::Zero
83
+ }
84
+
85
+ if ($QueryTitle) {
86
+ # In Windows Terminal this is the ACTIVE TAB's name. The cached handle wins,
87
+ # so a foreground window belonging to another app cannot answer for this one.
88
+ $titleWindow = [IntPtr]::Zero
89
+ if ($Hwnd -ne 0) { $titleWindow = [IntPtr]$Hwnd } else { $titleWindow = Resolve-TerminalWindow }
90
+ if ($titleWindow -ne [IntPtr]::Zero) {
91
+ $buffer = New-Object System.Text.StringBuilder -ArgumentList 256
92
+ [void][AuraFrame]::GetWindowText($titleWindow, $buffer, $buffer.Capacity)
93
+ Write-Output $buffer.ToString()
94
+ }
95
+ exit 0
96
+ }
97
+
98
+ $target = [IntPtr]::Zero
99
+ if ($Hwnd -ne 0) { $target = [IntPtr]$Hwnd } else { $target = Resolve-TerminalWindow }
100
+
101
+ if ($target -ne [IntPtr]::Zero) {
102
+ # The resolved window may differ from the cached one, so the skip list is
103
+ # checked here: a repo session's color outranks a bare shell's reset.
104
+ $skip = $false
105
+ if ($Reset -and $SkipHwnds -ne "") {
106
+ $skip = ($SkipHwnds -split "," | Where-Object { $_ -eq ([int64]$target).ToString() }).Count -gt 0
107
+ }
108
+ if ($Reset -and -not $skip) {
109
+ $default = -1 # DWMWA_COLOR_DEFAULT (0xFFFFFFFF)
110
+ [void][AuraFrame]::DwmSetWindowAttribute($target, 34, [ref]$default, 4)
111
+ [void][AuraFrame]::DwmSetWindowAttribute($target, 35, [ref]$default, 4)
112
+ }
113
+ # -NoPaint callers want the handle only.
114
+ if (-not $NoPaint) {
115
+ # COLORREF is 0x00BBGGRR.
116
+ $r = [Convert]::ToInt32($FrameColor.Substring(0, 2), 16)
117
+ $g = [Convert]::ToInt32($FrameColor.Substring(2, 2), 16)
118
+ $b = [Convert]::ToInt32($FrameColor.Substring(4, 2), 16)
119
+ $colorref = ($b -shl 16) -bor ($g -shl 8) -bor $r
120
+ [void][AuraFrame]::DwmSetWindowAttribute($target, 34, [ref]$colorref, 4) # DWMWA_BORDER_COLOR
121
+ [void][AuraFrame]::DwmSetWindowAttribute($target, 35, [ref]$colorref, 4) # DWMWA_CAPTION_COLOR
122
+ }
123
+ Write-Output ("" + [int64]$target)
124
+ } else {
125
+ Write-Output "0"
126
+ }
127
+
128
+ if ($VtB64 -ne "") {
129
+ try {
130
+ $bytes = [Convert]::FromBase64String($VtB64)
131
+ # One bulk pid->ppid query, then walk this process's ancestry.
132
+ $parentOf = @{}
133
+ foreach ($p in Get-CimInstance Win32_Process -Property ProcessId, ParentProcessId) {
134
+ $parentOf[[uint32]$p.ProcessId] = [uint32]$p.ParentProcessId
135
+ }
136
+ $chain = @()
137
+ $cur = [uint32]$PID
138
+ for ($i = 0; $i -lt 12; $i++) {
139
+ if (-not $parentOf.ContainsKey($cur)) { break }
140
+ $up = $parentOf[$cur]
141
+ if ($up -eq 0 -or -not $parentOf.ContainsKey($up)) { break }
142
+ $chain += $up
143
+ $cur = $up
144
+ }
145
+ # Topmost first: the highest attachable ancestor owns the tab console;
146
+ # nearer ancestors (the hook's own node/cmd) hold the hidden one.
147
+ [array]::Reverse($chain)
148
+ if ($VtDelayMs -gt 0) {
149
+ # An immediate write races the TUI init, so hand off to a grandchild.
150
+ if ($chain.Length -gt 0) {
151
+ $argList = @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", $PSCommandPath,
152
+ "-FrameColor", $FrameColor, "-VtB64", $VtB64,
153
+ "-VtTargets", ($chain -join ","), "-VtDelayMs", $VtDelayMs)
154
+ if ($StateFile -ne "") { $argList += @("-StateFile", $StateFile) }
155
+ if ($SessionId -ne "") { $argList += @("-SessionId", $SessionId) }
156
+ Start-Process -WindowStyle Hidden -FilePath "powershell.exe" -ArgumentList $argList
157
+ }
158
+ } else {
159
+ foreach ($ancestor in $chain) {
160
+ if ([AuraFrame]::WriteVt($ancestor, $bytes)) { break }
161
+ }
162
+ }
163
+ } catch {}
164
+ }
package/src/color.js ADDED
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+
3
+ // THE COLOR CONTRACT. Lane B inherits this file unchanged. Pure: no I/O.
4
+ // Mapping changes are breaking changes (CLAUDE.md rule 7); read
5
+ // docs/ARCHITECTURE.md "The Color Contract" first.
6
+
7
+ const SHADE_COUNT = 4;
8
+ const BASE_BRANCHES = new Set(["main", "master"]);
9
+
10
+ // Frame lightness per shade step. Spread wide so shades stay tellable-apart.
11
+ const FRAME_LIGHTNESS = [50, 63, 39, 72];
12
+ // Tint lightness per shade step. All inside the dark, readable band.
13
+ const TINT_LIGHTNESS = [13, 16, 10, 19];
14
+
15
+ function fnv1a(text) {
16
+ let hash = 0x811c9dc5;
17
+ for (let i = 0; i < text.length; i++) {
18
+ hash ^= text.charCodeAt(i);
19
+ hash = Math.imul(hash, 0x01000193) >>> 0;
20
+ }
21
+ return hash >>> 0;
22
+ }
23
+
24
+ function hslToHex(hue, saturationPct, lightnessPct) {
25
+ const s = saturationPct / 100;
26
+ const l = lightnessPct / 100;
27
+ const k = (n) => (n + hue / 30) % 12;
28
+ const a = s * Math.min(l, 1 - l);
29
+ const channel = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
30
+ const toHex = (v) => Math.round(v * 255).toString(16).padStart(2, "0");
31
+ return "#" + toHex(channel(0)) + toHex(channel(8)) + toHex(channel(4));
32
+ }
33
+
34
+ // repoId: origin remote URL, else repo root path, else cwd (non-repo session).
35
+ // branch: git branch name, or null outside a repo.
36
+ function colorsFor({ repoId, branch }) {
37
+ const hue = fnv1a(String(repoId)) % 360;
38
+ const isBase = branch === null || branch === undefined || branch === "" || BASE_BRANCHES.has(branch);
39
+ const shadeIndex = isBase ? 0 : 1 + (fnv1a(String(branch)) % (SHADE_COUNT - 1));
40
+ return {
41
+ hue,
42
+ shadeIndex,
43
+ tintHex: hslToHex(hue, 35, TINT_LIGHTNESS[shadeIndex]),
44
+ frameHex: hslToHex(hue, 70, FRAME_LIGHTNESS[shadeIndex]),
45
+ };
46
+ }
47
+
48
+ module.exports = { colorsFor, fnv1a, hslToHex };