claude-garage 0.2.0 → 0.3.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/README.md +134 -62
- package/bin/garage.js +358 -3
- package/daemon/src/claude-home.js +16 -0
- package/daemon/src/diff.js +1 -1
- package/daemon/src/events.js +6 -2
- package/daemon/src/health.js +28 -0
- package/daemon/src/hooks.js +18 -110
- package/daemon/src/index.js +6 -1
- package/daemon/src/poller.js +90 -23
- package/daemon/src/registry.js +3 -1
- package/daemon/src/session-resolve.js +58 -0
- package/daemon/src/sessions.js +93 -10
- package/daemon/src/settings-install.js +62 -0
- package/daemon/src/status.js +36 -26
- package/daemon/src/statusline.js +200 -0
- package/daemon/src/tmux.js +76 -16
- package/daemon/src/token-auth.js +13 -0
- package/daemon/src/transcript.js +142 -0
- package/daemon/src/worktrees.js +5 -1
- package/package.json +6 -3
- package/ui/dist/assets/index-bVL5DcKG.js +81 -0
- package/ui/dist/assets/{index-B9ScE6Rx.css → index-u3ZEnG0l.css} +2 -2
- package/ui/dist/index.html +2 -2
- package/wall/dist/garage-wall-darwin-arm64 +0 -0
- package/ui/dist/assets/index-CcUi48Ye.js +0 -81
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { getHookToken } from "./registry.js";
|
|
4
|
+
import { tokenMatches } from "./token-auth.js";
|
|
5
|
+
import { resolveSessionIds } from "./session-resolve.js";
|
|
6
|
+
import { readSettingsOrRefuse, writeSettingsAtomic } from "./settings-install.js";
|
|
7
|
+
import { claudeHome } from "./claude-home.js";
|
|
8
|
+
|
|
9
|
+
const PORT = Number(process.env.GARAGE_PORT ?? 4747);
|
|
10
|
+
const SETTINGS_PATH = join(claudeHome(), "settings.json");
|
|
11
|
+
const WRAPPER_PATH = join(claudeHome(), "garage-statusline.sh");
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// In-memory store — mirrors status.js's shape (a Map, no persistence, wiped
|
|
15
|
+
// per the id it's keyed on). Two slots: per-session context percentage, and
|
|
16
|
+
// ONE account-wide rate-limits record (statusline posts carry the whole
|
|
17
|
+
// account's rate limits on every post, not a per-session figure).
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
const contextStore = new Map(); // garage session id -> { usedPercentage }
|
|
21
|
+
let rateLimits = { fiveHour: null, sevenDay: null };
|
|
22
|
+
|
|
23
|
+
// Exported so the pure clamp logic is directly unit-testable (mirrors
|
|
24
|
+
// hooks.js's isIdleReminder being exported for the same reason).
|
|
25
|
+
export function clampPercentage(value) {
|
|
26
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
27
|
+
return Math.min(100, Math.max(0, value));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function setContext(id, usedPercentage) {
|
|
31
|
+
contextStore.set(id, { usedPercentage });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Read shape matches what sessions.js puts on the listing entry directly:
|
|
35
|
+
// {usedPercentage, source: "statusline"} | null.
|
|
36
|
+
export function getStatuslineContext(id) {
|
|
37
|
+
const entry = contextStore.get(id);
|
|
38
|
+
return entry ? { usedPercentage: entry.usedPercentage, source: "statusline" } : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Called (like status.js's dropSession) when a session disappears from tmux,
|
|
42
|
+
// so a future reuse of the same garage session id starts clean — see
|
|
43
|
+
// poller.js. Also usable directly by tests.
|
|
44
|
+
export function dropSession(id) {
|
|
45
|
+
contextStore.delete(id);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeLimit(limit) {
|
|
49
|
+
if (!limit || typeof limit !== "object") return null;
|
|
50
|
+
const usedPercentage = clampPercentage(limit.used_percentage);
|
|
51
|
+
if (usedPercentage === null) return null;
|
|
52
|
+
return { usedPercentage, resetsAt: limit.resets_at ?? null };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Partial-update semantics: a post carrying only one of the two buckets
|
|
56
|
+
// updates that bucket and leaves the other exactly as it was — a statusline
|
|
57
|
+
// post's rate_limits object isn't guaranteed to always carry both.
|
|
58
|
+
export function setRateLimits(raw) {
|
|
59
|
+
if (!raw || typeof raw !== "object") return;
|
|
60
|
+
const fiveHour = normalizeLimit(raw.five_hour);
|
|
61
|
+
const sevenDay = normalizeLimit(raw.seven_day);
|
|
62
|
+
if (fiveHour) rateLimits = { ...rateLimits, fiveHour };
|
|
63
|
+
if (sevenDay) rateLimits = { ...rateLimits, sevenDay };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getRateLimits() {
|
|
67
|
+
return { fiveHour: rateLimits.fiveHour, sevenDay: rateLimits.sevenDay };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Chaining wrapper — see design.md D-wrapper. The wrapper is a tiny POSIX sh
|
|
72
|
+
// script (not an inline settings.json command string) so chaining an
|
|
73
|
+
// arbitrary pre-existing command never fights JSON/shell quoting inside
|
|
74
|
+
// settings.json itself; settings.json only ever gets a fixed `sh <path>`
|
|
75
|
+
// command pointing at it.
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
// Single-quote a string for safe embedding in a POSIX sh script: close the
|
|
79
|
+
// quote, emit an escaped literal quote, reopen it.
|
|
80
|
+
function shSingleQuote(str) {
|
|
81
|
+
return `'${String(str).replace(/'/g, `'\\''`)}'`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// `originalCommand` is the pre-existing statusLine command to chain (its
|
|
85
|
+
// stdout must ride through unchanged), or null when there was none.
|
|
86
|
+
export function wrapperScript({ token, port = PORT, originalCommand = null }) {
|
|
87
|
+
const url = `http://127.0.0.1:${port}/api/statusline/claude?token=${token}`;
|
|
88
|
+
const chain = originalCommand
|
|
89
|
+
? `printf '%s' "$INPUT" | sh -c ${shSingleQuote(originalCommand)}\n`
|
|
90
|
+
: ""; // no pre-existing statusline — post and print nothing, per spec
|
|
91
|
+
return (
|
|
92
|
+
`#!/bin/sh\n` +
|
|
93
|
+
`# claude-garage statusline wrapper — auto-generated, do not hand-edit.\n` +
|
|
94
|
+
`# Reinstall (POST /api/statusline/install) regenerates this file.\n` +
|
|
95
|
+
`INPUT="$(cat)"\n` +
|
|
96
|
+
`printf '%s' "$INPUT" | curl -fsS --max-time 1 -X POST ${shSingleQuote(url)} ` +
|
|
97
|
+
`-H "Content-Type: application/json" --data-binary @- >/dev/null 2>&1 &\n` +
|
|
98
|
+
chain
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function wrapperCommand() {
|
|
103
|
+
return `sh ${shSingleQuote(WRAPPER_PATH)}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function writeWrapperScript(content) {
|
|
107
|
+
await mkdir(dirname(WRAPPER_PATH), { recursive: true });
|
|
108
|
+
const tmp = `${WRAPPER_PATH}.garage-tmp-${process.pid}`;
|
|
109
|
+
await writeFile(tmp, content, "utf8");
|
|
110
|
+
await chmod(tmp, 0o755);
|
|
111
|
+
await rename(tmp, WRAPPER_PATH);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function statuslineSnippet() {
|
|
115
|
+
const token = await getHookToken();
|
|
116
|
+
return {
|
|
117
|
+
statusLine: { type: "command", command: wrapperCommand() },
|
|
118
|
+
script: wrapperScript({ token, originalCommand: null }),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Same safety order as hooks.js's installHooks: parse-or-refuse -> compute
|
|
123
|
+
// the merge -> backup + atomic write only if something actually changes.
|
|
124
|
+
// Idempotency: if settings.statusLine.command already IS our wrapper
|
|
125
|
+
// command, there's nothing new to capture or write (see design.md's note on
|
|
126
|
+
// the GARAGE_PORT-change edge case — out of scope here, matches hooks.js's
|
|
127
|
+
// own idempotency granularity).
|
|
128
|
+
async function installStatusline() {
|
|
129
|
+
const { settings, raw } = await readSettingsOrRefuse(SETTINGS_PATH);
|
|
130
|
+
const token = await getHookToken();
|
|
131
|
+
const command = wrapperCommand();
|
|
132
|
+
|
|
133
|
+
const existing = settings.statusLine ?? null;
|
|
134
|
+
if (existing && existing.command === command) {
|
|
135
|
+
return { ok: true, installed: true, alreadyInstalled: true, chained: null, backup: null };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Only a `type: "command"` statusLine has a shell command we can chain;
|
|
139
|
+
// anything else (absent, or an unrecognized future type) is treated as
|
|
140
|
+
// "nothing to chain" rather than erroring — the wrapper simply posts and
|
|
141
|
+
// prints nothing, same as a fresh install.
|
|
142
|
+
const originalCommand =
|
|
143
|
+
existing && existing.type === "command" && typeof existing.command === "string"
|
|
144
|
+
? existing.command
|
|
145
|
+
: null;
|
|
146
|
+
|
|
147
|
+
await writeWrapperScript(wrapperScript({ token, originalCommand }));
|
|
148
|
+
|
|
149
|
+
const merged = { ...settings, statusLine: { type: "command", command } };
|
|
150
|
+
const backup = await writeSettingsAtomic(SETTINGS_PATH, raw, merged);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
ok: true,
|
|
154
|
+
installed: true,
|
|
155
|
+
alreadyInstalled: false,
|
|
156
|
+
chained: originalCommand !== null,
|
|
157
|
+
backup,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export default async function statuslineRoutes(app) {
|
|
162
|
+
// Auth: same per-install token as hooks.js's /api/hooks/claude — the
|
|
163
|
+
// wrapper script is URL-only config (no way to send a header), so the
|
|
164
|
+
// token rides as a query param.
|
|
165
|
+
app.post("/api/statusline/claude", async (req, reply) => {
|
|
166
|
+
const expected = await getHookToken();
|
|
167
|
+
if (!tokenMatches(req.query?.token, expected)) {
|
|
168
|
+
return reply.code(401).send({ error: "missing or invalid hook token" });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const payload = req.body ?? {};
|
|
172
|
+
const ids = await resolveSessionIds(payload);
|
|
173
|
+
if (ids.length === 0) {
|
|
174
|
+
// Unresolvable session — no error, nothing recorded (context or
|
|
175
|
+
// account-wide rate limits): a post that can't be attributed to a
|
|
176
|
+
// known garage session tells us nothing trustworthy.
|
|
177
|
+
return reply.code(200).send({ ok: true, ignored: true });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const pct = clampPercentage(payload?.context_window?.used_percentage);
|
|
181
|
+
if (pct !== null) {
|
|
182
|
+
for (const id of ids) setContext(id, pct);
|
|
183
|
+
}
|
|
184
|
+
setRateLimits(payload?.rate_limits);
|
|
185
|
+
|
|
186
|
+
return reply.code(200).send({ ok: true, applied: ids });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
app.get("/api/statusline/snippet", async () => statuslineSnippet());
|
|
190
|
+
|
|
191
|
+
app.post("/api/statusline/install", async (req, reply) => {
|
|
192
|
+
try {
|
|
193
|
+
return await installStatusline();
|
|
194
|
+
} catch (err) {
|
|
195
|
+
return reply
|
|
196
|
+
.code(err.statusCode ?? 500)
|
|
197
|
+
.send({ error: err.message ?? "statusline install failed" });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
package/daemon/src/tmux.js
CHANGED
|
@@ -19,7 +19,10 @@ export async function listSessions() {
|
|
|
19
19
|
({ stdout } = await run("tmux", [
|
|
20
20
|
"ls",
|
|
21
21
|
"-F",
|
|
22
|
-
|
|
22
|
+
// `session_created` (epoch SECONDS) backs the UI's elapsed timer for a
|
|
23
|
+
// session that has never had a status transition — without it a live
|
|
24
|
+
// session with no recorded `since` renders "—" instead of its age.
|
|
25
|
+
"#{session_name}\t#{session_path}\t#{session_attached}\t#{session_created}",
|
|
23
26
|
]));
|
|
24
27
|
} catch {
|
|
25
28
|
// tmux exits non-zero when no server is running — that means "no sessions".
|
|
@@ -29,20 +32,41 @@ export async function listSessions() {
|
|
|
29
32
|
.split("\n")
|
|
30
33
|
.filter((line) => line.startsWith(GARAGE_PREFIX))
|
|
31
34
|
.map((line) => {
|
|
32
|
-
const [name, dir, attached] = line.split("\t");
|
|
35
|
+
const [name, dir, attached, created] = line.split("\t");
|
|
33
36
|
const [, workspace, label] = name.split("/");
|
|
34
|
-
|
|
37
|
+
const createdMs = Number(created) * 1000;
|
|
38
|
+
return {
|
|
39
|
+
id: name,
|
|
40
|
+
workspace,
|
|
41
|
+
label,
|
|
42
|
+
dir,
|
|
43
|
+
attached: attached !== "0",
|
|
44
|
+
createdAt: Number.isFinite(createdMs) && createdMs > 0 ? createdMs : null,
|
|
45
|
+
};
|
|
35
46
|
});
|
|
36
47
|
}
|
|
37
48
|
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
|
|
49
|
+
// p10: pane titles are free text — tmux doesn't forbid a literal tab in one
|
|
50
|
+
// (unlikely, but "typically" isn't "never"). `#{pane_title}` sits LAST in
|
|
51
|
+
// the format string and everything from the second tab onward is rejoined
|
|
52
|
+
// into the title field, so an embedded separator there can never shift
|
|
53
|
+
// session_name/pane_current_path out from under the earlier destructuring.
|
|
54
|
+
// Exported (pure, no tmux call) so the split logic is unit-testable without
|
|
55
|
+
// a live server.
|
|
56
|
+
export function parsePaneLine(line) {
|
|
57
|
+
const [name, panePath, ...rest] = line.split("\t");
|
|
58
|
+
return { name, panePath, title: rest.join("\t") };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// D-branch: live pane cwd + title per garage session, keyed by session name
|
|
62
|
+
// — NOT `session_path` (that's the session's *starting* dir, frozen at
|
|
63
|
+
// creation; a user who `cd`s into a worktree inside the pane changes
|
|
64
|
+
// pane_current_path but not session_path). `-a` lists every pane on the
|
|
65
|
+
// server across every session, so results are filtered to the garage prefix
|
|
66
|
+
// same as listSessions(). A session can have multiple panes/windows if the
|
|
67
|
+
// user split it manually — only the first pane encountered per session is
|
|
68
|
+
// kept, there's no principled way to pick "the" cwd/title of a multi-pane
|
|
69
|
+
// session.
|
|
46
70
|
export async function listPanePaths() {
|
|
47
71
|
let stdout;
|
|
48
72
|
try {
|
|
@@ -50,19 +74,55 @@ export async function listPanePaths() {
|
|
|
50
74
|
"list-panes",
|
|
51
75
|
"-a",
|
|
52
76
|
"-F",
|
|
53
|
-
"#{session_name}\t#{pane_current_path}",
|
|
77
|
+
"#{session_name}\t#{pane_current_path}\t#{pane_title}",
|
|
54
78
|
]));
|
|
55
79
|
} catch {
|
|
56
80
|
// No tmux server running — no panes.
|
|
57
81
|
return new Map();
|
|
58
82
|
}
|
|
59
|
-
const
|
|
83
|
+
const panes = new Map();
|
|
60
84
|
for (const line of stdout.split("\n")) {
|
|
61
85
|
if (!line.startsWith(GARAGE_PREFIX)) continue;
|
|
62
|
-
const
|
|
63
|
-
if (!
|
|
86
|
+
const { name, panePath, title } = parsePaneLine(line);
|
|
87
|
+
if (!panes.has(name)) panes.set(name, { path: panePath, title });
|
|
64
88
|
}
|
|
65
|
-
return
|
|
89
|
+
return panes;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// p10: tmux's own default pane titles carry no signal — empty, the machine
|
|
93
|
+
// hostname (tmux's out-of-the-box default), or the bare shell/login-shell
|
|
94
|
+
// name it sets before anything else runs. Filtering those down to null
|
|
95
|
+
// means the UI only ever sees a title something *set* (e.g. Claude Code's
|
|
96
|
+
// OSC title updates). Pure — hostname is passed in rather than read via
|
|
97
|
+
// os.hostname() internally so this stays trivially unit-testable; the one
|
|
98
|
+
// production caller (sessions.js) passes os.hostname().
|
|
99
|
+
const BARE_PROCESS_NAMES = new Set([
|
|
100
|
+
"sh",
|
|
101
|
+
"bash",
|
|
102
|
+
"zsh",
|
|
103
|
+
"fish",
|
|
104
|
+
"-sh",
|
|
105
|
+
"-bash",
|
|
106
|
+
"-zsh",
|
|
107
|
+
"-fish",
|
|
108
|
+
"tmux",
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
export function normalizeTitle(title, hostname) {
|
|
112
|
+
if (title == null) return null;
|
|
113
|
+
const trimmed = title.trim();
|
|
114
|
+
if (!trimmed) return null;
|
|
115
|
+
|
|
116
|
+
const lower = trimmed.toLowerCase();
|
|
117
|
+
if (BARE_PROCESS_NAMES.has(lower)) return null;
|
|
118
|
+
|
|
119
|
+
if (hostname) {
|
|
120
|
+
const hostLower = hostname.toLowerCase();
|
|
121
|
+
const shortHost = hostLower.split(".")[0];
|
|
122
|
+
if (lower === hostLower || lower === shortHost) return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return trimmed;
|
|
66
126
|
}
|
|
67
127
|
|
|
68
128
|
// D-branch: shared by sessions.js (per-session, keyed off live pane cwd)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// Shared by hooks.js and statusline.js — both authenticate a URL query-param
|
|
4
|
+
// token (Claude Code's HTTP hooks and the statusline wrapper are both
|
|
5
|
+
// URL-only config) against the same per-install hook token from
|
|
6
|
+
// registry.js. Constant-time compare so response timing can't leak the
|
|
7
|
+
// expected token a byte at a time.
|
|
8
|
+
export function tokenMatches(given, expected) {
|
|
9
|
+
if (typeof given !== "string") return false;
|
|
10
|
+
const a = Buffer.from(given);
|
|
11
|
+
const b = Buffer.from(expected);
|
|
12
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
13
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { claudeHome } from "./claude-home.js";
|
|
4
|
+
|
|
5
|
+
const TAIL_BYTES = 256 * 1024;
|
|
6
|
+
const TTL_MS = 15_000;
|
|
7
|
+
const DEFAULT_WINDOW = 200_000;
|
|
8
|
+
|
|
9
|
+
// Claude Code's own project-directory naming: the cwd with every `/`
|
|
10
|
+
// replaced by `-` (leading slash included). Exported — pure — for direct
|
|
11
|
+
// testing.
|
|
12
|
+
export function slugifyCwd(cwd) {
|
|
13
|
+
return cwd.replaceAll("/", "-");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function transcriptPath(dir, claudeSessionId) {
|
|
17
|
+
return join(claudeHome(), "projects", slugifyCwd(dir), `${claudeSessionId}.jsonl`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Small map of known exceptions to the 200k default — "[1m]" marks the
|
|
21
|
+
// 1M-context beta variants of a model id (e.g.
|
|
22
|
+
// "claude-sonnet-4-5-20250929[1m]"). Anything else, including an unknown or
|
|
23
|
+
// missing model id, gets the default: a hint, not billing, per design.md.
|
|
24
|
+
export function windowForModel(model) {
|
|
25
|
+
if (typeof model !== "string") return DEFAULT_WINDOW;
|
|
26
|
+
if (model.includes("[1m]")) return 1_000_000;
|
|
27
|
+
return DEFAULT_WINDOW;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Scans `text` (the tail-bounded transcript content) backward for the last
|
|
31
|
+
// assistant message carrying a `usage` block, skipping malformed JSON lines
|
|
32
|
+
// and any line that isn't an assistant/usage entry. Returns
|
|
33
|
+
// {tokens, model} | null. Pure — exported for direct unit tests.
|
|
34
|
+
export function parseLastAssistantUsage(text) {
|
|
35
|
+
const lines = text.split("\n");
|
|
36
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
37
|
+
const line = lines[i].trim();
|
|
38
|
+
if (!line) continue;
|
|
39
|
+
|
|
40
|
+
let entry;
|
|
41
|
+
try {
|
|
42
|
+
entry = JSON.parse(line);
|
|
43
|
+
} catch {
|
|
44
|
+
continue; // malformed line (or a partial line from the tail cut) — skip
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (entry?.type !== "assistant") continue;
|
|
48
|
+
const usage = entry?.message?.usage;
|
|
49
|
+
if (!usage || typeof usage !== "object") continue;
|
|
50
|
+
|
|
51
|
+
const input = Number(usage.input_tokens) || 0;
|
|
52
|
+
const cacheRead = Number(usage.cache_read_input_tokens) || 0;
|
|
53
|
+
const cacheCreation = Number(usage.cache_creation_input_tokens) || 0;
|
|
54
|
+
return { tokens: input + cacheRead + cacheCreation, model: entry.message.model ?? null };
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Reads only the last <=256KB of the file (a multi-MB transcript must never
|
|
60
|
+
// cost a full read). Returns the tail text, or null if the file doesn't
|
|
61
|
+
// exist / can't be read. The first line of the tail may be a partial line
|
|
62
|
+
// cut mid-record — parseLastAssistantUsage tolerates that by skipping
|
|
63
|
+
// unparseable lines, which is exactly what a truncated leading line is.
|
|
64
|
+
async function readTail(path) {
|
|
65
|
+
let handle;
|
|
66
|
+
try {
|
|
67
|
+
handle = await open(path, "r");
|
|
68
|
+
const { size } = await handle.stat();
|
|
69
|
+
const start = Math.max(0, size - TAIL_BYTES);
|
|
70
|
+
const length = size - start;
|
|
71
|
+
const buffer = Buffer.alloc(length);
|
|
72
|
+
if (length > 0) {
|
|
73
|
+
await handle.read(buffer, 0, length, start);
|
|
74
|
+
}
|
|
75
|
+
return buffer.toString("utf8");
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
} finally {
|
|
79
|
+
await handle?.close().catch(() => {});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The percentage the fallback exposes: (input + cache_read + cache_creation)
|
|
84
|
+
// over the model's context window. Async, does real file IO — callers on
|
|
85
|
+
// the hot path must use the cache below (getCachedContext/refreshContext),
|
|
86
|
+
// never call this directly from a request handler.
|
|
87
|
+
export async function computeContext({ dir, claudeSessionId }) {
|
|
88
|
+
if (!dir || !claudeSessionId) return null;
|
|
89
|
+
const tail = await readTail(transcriptPath(dir, claudeSessionId));
|
|
90
|
+
if (tail === null) return null;
|
|
91
|
+
|
|
92
|
+
const usage = parseLastAssistantUsage(tail);
|
|
93
|
+
if (!usage) return null; // no assistant usage within the tail-bounded read
|
|
94
|
+
|
|
95
|
+
const window = windowForModel(usage.model);
|
|
96
|
+
const usedPercentage = Math.min(100, Math.max(0, Math.round((usage.tokens / window) * 100)));
|
|
97
|
+
return { usedPercentage, source: "transcript" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Per-session cache: {value, at, pending}. `value` is null until the first
|
|
102
|
+
// successful (or empty) read completes. The sessions route (sessions.js)
|
|
103
|
+
// must NEVER block on a transcript read — it calls getCachedContext for the
|
|
104
|
+
// synchronous, always-available answer, and refreshContext to kick a
|
|
105
|
+
// background read when the cache is stale. refreshContext returns
|
|
106
|
+
// immediately in every case; its promise is intentionally not returned so a
|
|
107
|
+
// caller can't accidentally await it into the request path.
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
const cache = new Map(); // garage session id -> {value, at, pending}
|
|
111
|
+
|
|
112
|
+
export function getCachedContext(id) {
|
|
113
|
+
return cache.get(id)?.value ?? null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Exposes the full cache entry (including `at`) — mainly for tests to
|
|
117
|
+
// observe TTL behavior without waiting on wall-clock time.
|
|
118
|
+
export function getCacheEntry(id) {
|
|
119
|
+
return cache.get(id) ?? { value: null, at: null };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function clearCache(id) {
|
|
123
|
+
cache.delete(id);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function refreshContext(id, { dir, claudeSessionId }) {
|
|
127
|
+
const entry = cache.get(id);
|
|
128
|
+
const fresh = entry && Date.now() - entry.at < TTL_MS;
|
|
129
|
+
if (fresh || entry?.pending) return;
|
|
130
|
+
|
|
131
|
+
cache.set(id, { value: entry?.value ?? null, at: entry?.at ?? 0, pending: true });
|
|
132
|
+
computeContext({ dir, claudeSessionId })
|
|
133
|
+
.then((value) => {
|
|
134
|
+
cache.set(id, { value, at: Date.now(), pending: false });
|
|
135
|
+
})
|
|
136
|
+
.catch(() => {
|
|
137
|
+
// A read failure keeps whatever was already cached (stale-but-known
|
|
138
|
+
// beats null) but still stamps `at` so a persistently failing read
|
|
139
|
+
// doesn't retry every single request.
|
|
140
|
+
cache.set(id, { value: entry?.value ?? null, at: Date.now(), pending: false });
|
|
141
|
+
});
|
|
142
|
+
}
|
package/daemon/src/worktrees.js
CHANGED
|
@@ -10,7 +10,11 @@ const run = promisify(execFile);
|
|
|
10
10
|
// D-wt-location: outside any repo, so a worktree never pollutes `git status`
|
|
11
11
|
// for the repo itself. Keyed by workspace so `~/.garage/worktrees/<ws>/` is
|
|
12
12
|
// one place to `ls` per workspace.
|
|
13
|
-
|
|
13
|
+
// Follows the registry's GARAGE_DIR override (testability — see registry.js).
|
|
14
|
+
const WORKTREES_ROOT = path.join(
|
|
15
|
+
process.env.GARAGE_DIR ?? path.join(homedir(), ".garage"),
|
|
16
|
+
"worktrees"
|
|
17
|
+
);
|
|
14
18
|
|
|
15
19
|
// D-wt-branch: namespaced so a repo's real branches never collide with ours,
|
|
16
20
|
// and so the discard/merge cleanup guard below can refuse to touch anything
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-garage",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Multiple Claude agents driving you crazy? Park them all in one garage:
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Multiple Claude agents driving you crazy? Park them all in one garage: a needs-input queue that follows you across projects, a full-screen terminal TUI, and a browser wall with diff review. tmux owns the sessions; close the app and nothing dies.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Saiful Islam <saifulislamadar@gmail.com>",
|
|
7
7
|
"repository": {
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"claude",
|
|
15
15
|
"claude-code",
|
|
16
16
|
"tmux",
|
|
17
|
+
"tui",
|
|
17
18
|
"terminal",
|
|
18
19
|
"agents",
|
|
19
20
|
"session-manager",
|
|
@@ -33,7 +34,8 @@
|
|
|
33
34
|
"bin",
|
|
34
35
|
"daemon/src",
|
|
35
36
|
"daemon/package.json",
|
|
36
|
-
"ui/dist"
|
|
37
|
+
"ui/dist",
|
|
38
|
+
"wall/dist"
|
|
37
39
|
],
|
|
38
40
|
"workspaces": [
|
|
39
41
|
"daemon",
|
|
@@ -42,6 +44,7 @@
|
|
|
42
44
|
"scripts": {
|
|
43
45
|
"dev": "concurrently -n daemon,ui -c blue,magenta \"npm:dev --workspace daemon\" \"npm:dev --workspace ui\"",
|
|
44
46
|
"test": "node --test \"daemon/test/*.test.js\" \"ui/test/*.test.js\"",
|
|
47
|
+
"build:tui": "cd wall && cargo build --release && mkdir -p dist && cp target/release/garage-wall dist/garage-wall-$(node -p \"process.platform+'-'+process.arch\")",
|
|
45
48
|
"postinstall": "chmod +x node_modules/node-pty/prebuilds/darwin-*/spawn-helper 2>/dev/null || true",
|
|
46
49
|
"prepack": "npm run build --workspace ui"
|
|
47
50
|
},
|