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
package/daemon/src/poller.js
CHANGED
|
@@ -1,19 +1,38 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
import { EventEmitter } from "node:events";
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { listSessions, normalizeTitle } from "./tmux.js";
|
|
6
|
+
import { setStatus, getStatus, dropSession as dropStatus } from "./status.js";
|
|
7
|
+
import { dropSession as dropStatuslineContext } from "./statusline.js";
|
|
6
8
|
import { upsertSessionMeta } from "./registry.js";
|
|
7
9
|
|
|
10
|
+
// p11: a dead session's statusline-fed context must be cleared alongside
|
|
11
|
+
// its status entry (context-telemetry spec) — a future reuse of the same
|
|
12
|
+
// garage session id must never inherit a stale percentage from a prior,
|
|
13
|
+
// unrelated conversation.
|
|
14
|
+
function dropSession(id) {
|
|
15
|
+
dropStatus(id);
|
|
16
|
+
dropStatuslineContext(id);
|
|
17
|
+
}
|
|
18
|
+
|
|
8
19
|
const run = promisify(execFile);
|
|
9
20
|
const POLL_MS = 2000;
|
|
10
21
|
|
|
11
|
-
// Emits "sessions-changed" whenever the poller notices the
|
|
12
|
-
//
|
|
22
|
+
// Emits "sessions-changed" whenever the poller notices something the wall's
|
|
23
|
+
// session listing depends on changed: the garage session set itself
|
|
24
|
+
// (spawn/death) OR — p10 — a live session's normalized pane title, so a
|
|
25
|
+
// `tmux select-pane -T` (e.g. Claude Code's OSC title) gets pushed to the
|
|
26
|
+
// UI instead of only being picked up on the next unrelated refetch.
|
|
13
27
|
export const pollerEvents = new EventEmitter();
|
|
14
28
|
pollerEvents.setMaxListeners(0);
|
|
15
29
|
|
|
16
30
|
let lastSessionIds = new Set();
|
|
31
|
+
// p10: normalized title per currently-live garage session, from the tick
|
|
32
|
+
// this map was last rebuilt in. Rebuilt wholesale every tick from `sessions`
|
|
33
|
+
// (never merged), so a session that dies has its entry dropped for free —
|
|
34
|
+
// memory-trivial, sessions-only.
|
|
35
|
+
let lastTitles = new Map();
|
|
17
36
|
|
|
18
37
|
// `claude agents --json` — missing CLI or any failure is a silent no-op,
|
|
19
38
|
// per D-status's graceful degradation to poller-absent behavior.
|
|
@@ -26,22 +45,26 @@ async function getAgents() {
|
|
|
26
45
|
}
|
|
27
46
|
}
|
|
28
47
|
|
|
29
|
-
// tmux pane pid per session name — pane_pid joins exactly to the
|
|
30
|
-
// reported by `claude agents --json` since garage spawns claude as the
|
|
31
|
-
// pane's root process (design D-status).
|
|
48
|
+
// tmux pane pid + title per session name — pane_pid joins exactly to the
|
|
49
|
+
// `pid` reported by `claude agents --json` since garage spawns claude as the
|
|
50
|
+
// pane's root process (design D-status). p10: pane_title rides the same
|
|
51
|
+
// list-panes call (kept to ONE per tick daemon-wide) so the title-change
|
|
52
|
+
// diff below needs no tmux invocation of its own; title is free text, so it
|
|
53
|
+
// sits last in the format string and everything past the second tab is
|
|
54
|
+
// rejoined into it (same rationale as tmux.js's parsePaneLine).
|
|
32
55
|
async function getPanePids() {
|
|
33
56
|
try {
|
|
34
57
|
const { stdout } = await run("tmux", [
|
|
35
58
|
"list-panes",
|
|
36
59
|
"-a",
|
|
37
60
|
"-F",
|
|
38
|
-
"#{session_name}\t#{pane_pid}",
|
|
61
|
+
"#{session_name}\t#{pane_pid}\t#{pane_title}",
|
|
39
62
|
]);
|
|
40
63
|
const map = new Map();
|
|
41
64
|
for (const line of stdout.split("\n")) {
|
|
42
65
|
if (!line.trim()) continue;
|
|
43
|
-
const [name, pid] = line.split("\t");
|
|
44
|
-
map.set(name, Number(pid));
|
|
66
|
+
const [name, pid, ...rest] = line.split("\t");
|
|
67
|
+
map.set(name, { pid: Number(pid), title: rest.join("\t") });
|
|
45
68
|
}
|
|
46
69
|
return map;
|
|
47
70
|
} catch {
|
|
@@ -65,38 +88,82 @@ export function applyAgentStatus(id, agentStatus) {
|
|
|
65
88
|
setStatus(id, mapped);
|
|
66
89
|
}
|
|
67
90
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
91
|
+
// p10 wave-2 fix: pure id+title diff, no tmux/IO — exported so it's
|
|
92
|
+
// exhaustively unit-testable without a live tmux server. `sessions` is this
|
|
93
|
+
// tick's listSessions() result; `panePids` maps session id -> {pid, title}
|
|
94
|
+
// (from this tick's single list-panes call — a session can be missing an
|
|
95
|
+
// entry, e.g. mid-spawn); `prevIds`/`prevTitles` are the previous tick's
|
|
96
|
+
// state. Session-id-set changes (spawn/death) AND a normalized-title change
|
|
97
|
+
// on any still-live session both count as "changed" — a pane-title-only
|
|
98
|
+
// change (`tmux select-pane -T`) never touches the id set, so without the
|
|
99
|
+
// title half this would sit unpushed until some unrelated refetch noticed.
|
|
100
|
+
export function diffSessionState(sessions, panePids, hostname, prevIds, prevTitles) {
|
|
71
101
|
const currentIds = new Set(sessions.map((s) => s.id));
|
|
72
|
-
let changed = currentIds.size !==
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
102
|
+
let changed = currentIds.size !== prevIds.size;
|
|
103
|
+
if (!changed) {
|
|
104
|
+
for (const id of prevIds) {
|
|
105
|
+
if (!currentIds.has(id)) {
|
|
106
|
+
changed = true;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
77
109
|
}
|
|
78
110
|
}
|
|
79
111
|
if (!changed) {
|
|
80
112
|
for (const id of currentIds) {
|
|
81
|
-
if (!
|
|
113
|
+
if (!prevIds.has(id)) {
|
|
82
114
|
changed = true;
|
|
83
115
|
break;
|
|
84
116
|
}
|
|
85
117
|
}
|
|
86
118
|
}
|
|
87
|
-
lastSessionIds = currentIds;
|
|
88
|
-
if (changed) pollerEvents.emit("sessions-changed");
|
|
89
119
|
|
|
90
|
-
|
|
120
|
+
const titles = new Map();
|
|
121
|
+
for (const session of sessions) {
|
|
122
|
+
const title = normalizeTitle(panePids.get(session.id)?.title, hostname);
|
|
123
|
+
titles.set(session.id, title);
|
|
124
|
+
if (prevTitles.get(session.id) !== title) changed = true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { changed, ids: currentIds, titles };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function tick() {
|
|
131
|
+
const sessions = await listSessions().catch(() => []);
|
|
132
|
+
|
|
133
|
+
if (sessions.length === 0) {
|
|
134
|
+
for (const id of lastSessionIds) dropSession(id);
|
|
135
|
+
const changed = lastSessionIds.size !== 0;
|
|
136
|
+
lastSessionIds = new Set();
|
|
137
|
+
lastTitles = new Map();
|
|
138
|
+
if (changed) pollerEvents.emit("sessions-changed");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
91
141
|
|
|
142
|
+
// p10: title rides the same list-panes call already made for the pid
|
|
143
|
+
// join — kept to ONE list-panes invocation per tick daemon-wide.
|
|
92
144
|
const [agents, panePids] = await Promise.all([getAgents(), getPanePids()]);
|
|
145
|
+
const hostname = os.hostname();
|
|
146
|
+
const { changed, ids, titles } = diffSessionState(
|
|
147
|
+
sessions,
|
|
148
|
+
panePids,
|
|
149
|
+
hostname,
|
|
150
|
+
lastSessionIds,
|
|
151
|
+
lastTitles
|
|
152
|
+
);
|
|
153
|
+
for (const id of lastSessionIds) {
|
|
154
|
+
if (!ids.has(id)) dropSession(id);
|
|
155
|
+
}
|
|
156
|
+
lastSessionIds = ids;
|
|
157
|
+
lastTitles = titles;
|
|
158
|
+
if (changed) pollerEvents.emit("sessions-changed");
|
|
159
|
+
|
|
93
160
|
if (agents.length === 0) return; // claude CLI missing/no live agents
|
|
94
161
|
|
|
95
162
|
const agentByPid = new Map(agents.map((a) => [a.pid, a]));
|
|
96
163
|
const unmatched = [];
|
|
97
164
|
|
|
98
165
|
for (const session of sessions) {
|
|
99
|
-
const panePid = panePids.get(session.id);
|
|
166
|
+
const panePid = panePids.get(session.id)?.pid;
|
|
100
167
|
const agent = panePid !== undefined ? agentByPid.get(panePid) : undefined;
|
|
101
168
|
if (agent) {
|
|
102
169
|
applyAgentStatus(session.id, agent.status);
|
package/daemon/src/registry.js
CHANGED
|
@@ -8,7 +8,9 @@ import { GARAGE_PREFIX } from "./tmux.js";
|
|
|
8
8
|
// Created lazily on first write — a user who registers nothing has no file
|
|
9
9
|
// to migrate or corrupt. tmux ls remains the sole source of truth for
|
|
10
10
|
// sessions; this file is a directory mapping only, never a session list.
|
|
11
|
-
|
|
11
|
+
// GARAGE_DIR env override exists for testability (scratch daemons must not
|
|
12
|
+
// share the real ~/.garage/state.json) — same spirit as GARAGE_PORT.
|
|
13
|
+
const GARAGE_DIR = process.env.GARAGE_DIR ?? path.join(homedir(), ".garage");
|
|
12
14
|
const STATE_PATH = path.join(GARAGE_DIR, "state.json");
|
|
13
15
|
|
|
14
16
|
async function readState() {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { listSessions } from "./tmux.js";
|
|
4
|
+
|
|
5
|
+
const run = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
async function getAgents() {
|
|
8
|
+
try {
|
|
9
|
+
const { stdout } = await run("claude", ["agents", "--json"]);
|
|
10
|
+
return JSON.parse(stdout);
|
|
11
|
+
} catch {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function getPanePids() {
|
|
17
|
+
try {
|
|
18
|
+
const { stdout } = await run("tmux", [
|
|
19
|
+
"list-panes",
|
|
20
|
+
"-a",
|
|
21
|
+
"-F",
|
|
22
|
+
"#{session_name}\t#{pane_pid}",
|
|
23
|
+
]);
|
|
24
|
+
const map = new Map(); // pid -> session name
|
|
25
|
+
for (const line of stdout.split("\n")) {
|
|
26
|
+
if (!line.trim()) continue;
|
|
27
|
+
const [name, pid] = line.split("\t");
|
|
28
|
+
map.set(Number(pid), name);
|
|
29
|
+
}
|
|
30
|
+
return map;
|
|
31
|
+
} catch {
|
|
32
|
+
return new Map();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Shared by hooks.js and statusline.js — both receive a Claude Code payload
|
|
37
|
+
// carrying `session_id`/`cwd` and need the same garage session id(s) it
|
|
38
|
+
// applies to. Precise path: payload session_id -> agents-json sessionId ->
|
|
39
|
+
// its pid -> pane_pid join. Fallback: cwd fail-open, applied to every garage
|
|
40
|
+
// session sharing that dir (over-notify/over-record rather than silently
|
|
41
|
+
// drop).
|
|
42
|
+
export async function resolveSessionIds({ session_id, cwd }) {
|
|
43
|
+
const [agents, pidToSession] = await Promise.all([getAgents(), getPanePids()]);
|
|
44
|
+
|
|
45
|
+
if (session_id) {
|
|
46
|
+
const agent = agents.find((a) => a.sessionId === session_id);
|
|
47
|
+
const sessionName = agent && pidToSession.get(agent.pid);
|
|
48
|
+
if (sessionName) return [sessionName];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (cwd) {
|
|
52
|
+
const sessions = await listSessions();
|
|
53
|
+
const matches = sessions.filter((s) => s.dir === cwd).map((s) => s.id);
|
|
54
|
+
if (matches.length > 0) return matches;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return [];
|
|
58
|
+
}
|
package/daemon/src/sessions.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { stat } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
2
3
|
import {
|
|
3
4
|
GARAGE_PREFIX,
|
|
4
5
|
NAME_RE,
|
|
@@ -6,6 +7,7 @@ import {
|
|
|
6
7
|
listSessions,
|
|
7
8
|
listPanePaths,
|
|
8
9
|
resolveBranch,
|
|
10
|
+
normalizeTitle,
|
|
9
11
|
hasSession,
|
|
10
12
|
createSession,
|
|
11
13
|
killSession,
|
|
@@ -17,8 +19,10 @@ import {
|
|
|
17
19
|
upsertSessionMeta,
|
|
18
20
|
removeSessionMeta,
|
|
19
21
|
} from "./registry.js";
|
|
20
|
-
import {
|
|
22
|
+
import { getStatusEntry } from "./status.js";
|
|
21
23
|
import { createWorktree } from "./worktrees.js";
|
|
24
|
+
import { getStatuslineContext, getRateLimits } from "./statusline.js";
|
|
25
|
+
import { getCachedContext, refreshContext } from "./transcript.js";
|
|
22
26
|
|
|
23
27
|
const CLAUDE_CMD = process.env.GARAGE_CLAUDE_CMD ?? "claude";
|
|
24
28
|
|
|
@@ -26,6 +30,8 @@ export default async function sessionRoutes(app) {
|
|
|
26
30
|
app.get("/api/sessions", async () => {
|
|
27
31
|
const sessions = await listSessions();
|
|
28
32
|
const panePaths = await listPanePaths();
|
|
33
|
+
// p10: same tmux hostname for every session in this request — read once.
|
|
34
|
+
const hostname = os.hostname();
|
|
29
35
|
|
|
30
36
|
// D-branch: this route is re-hit on every SSE-driven refetch, so the
|
|
31
37
|
// git calls it triggers must stay cheap — one execFile per unique
|
|
@@ -41,15 +47,57 @@ export default async function sessionRoutes(app) {
|
|
|
41
47
|
return branchCache.get(dir);
|
|
42
48
|
};
|
|
43
49
|
|
|
50
|
+
// p11: fetched once up front (not per-session) — same cost discipline as
|
|
51
|
+
// branchCache above — so both the live loop's claudeSessionId lookup and
|
|
52
|
+
// the restorable loop below share one registry read.
|
|
53
|
+
const metas = await listSessionMetas();
|
|
54
|
+
const metaById = new Map(metas.map((m) => [m.id, m]));
|
|
55
|
+
|
|
44
56
|
const live = await Promise.all(
|
|
45
|
-
sessions.map(async (s) =>
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
sessions.map(async (s) => {
|
|
58
|
+
// since: epoch ms the current status began, so the UI can render
|
|
59
|
+
// elapsed time without a second lookup (see status.js getStatusEntry).
|
|
60
|
+
const { state: status, since, message } = getStatusEntry(s.id);
|
|
61
|
+
const pane = panePaths.get(s.id);
|
|
62
|
+
const dir = pane?.path ?? s.dir;
|
|
63
|
+
|
|
64
|
+
// p11: context — statusline data (pushed by the wrapper) beats the
|
|
65
|
+
// transcript fallback beats null. The transcript path is a cache
|
|
66
|
+
// read only: getCachedContext never blocks, refreshContext kicks a
|
|
67
|
+
// background read (a no-op if the cache is still fresh or a read is
|
|
68
|
+
// already in flight) — this route must never block on file IO.
|
|
69
|
+
let context = getStatuslineContext(s.id);
|
|
70
|
+
if (!context) {
|
|
71
|
+
context = getCachedContext(s.id);
|
|
72
|
+
const claudeSessionId = metaById.get(s.id)?.claudeSessionId;
|
|
73
|
+
if (claudeSessionId && dir) {
|
|
74
|
+
refreshContext(s.id, { dir, claudeSessionId });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
...s,
|
|
80
|
+
status,
|
|
81
|
+
// p8: the Notification hook's text for a needs-input session; the
|
|
82
|
+
// store guarantees null for every other state (see status.js).
|
|
83
|
+
message: message ?? null,
|
|
84
|
+
// A session that has never transitioned has no recorded `since`
|
|
85
|
+
// (the store only stamps one on a real state change), which would
|
|
86
|
+
// render as "—" in the UI even though the session is live and has
|
|
87
|
+
// an obvious age. Fall back to when tmux created it.
|
|
88
|
+
since: since ?? s.createdAt ?? null,
|
|
89
|
+
// Live pane cwd, not session_path (see listPanePaths) — falls back
|
|
90
|
+
// to session_path only if the pane vanished between the two tmux
|
|
91
|
+
// calls above (a session that died mid-request).
|
|
92
|
+
branch: await getBranch(dir),
|
|
93
|
+
// p10: the pane's OSC title (e.g. Claude Code's "✳ <summary>"),
|
|
94
|
+
// filtered down to null when it's just tmux's own default (empty,
|
|
95
|
+
// hostname, bare shell name) — see normalizeTitle.
|
|
96
|
+
title: normalizeTitle(pane?.title, hostname),
|
|
97
|
+
// p11: {usedPercentage, source: "statusline"|"transcript"} | null.
|
|
98
|
+
context,
|
|
99
|
+
};
|
|
100
|
+
})
|
|
53
101
|
);
|
|
54
102
|
const liveIds = new Set(live.map((s) => s.id));
|
|
55
103
|
|
|
@@ -57,7 +105,6 @@ export default async function sessionRoutes(app) {
|
|
|
57
105
|
// has no matching live tmux session. dir is re-resolved from the
|
|
58
106
|
// registry (not a cached copy in the meta record) so a workspace
|
|
59
107
|
// re-registered to a new path since the crash is reflected correctly.
|
|
60
|
-
const metas = await listSessionMetas();
|
|
61
108
|
const restorable = [];
|
|
62
109
|
for (const meta of metas) {
|
|
63
110
|
if (liveIds.has(meta.id)) continue;
|
|
@@ -70,16 +117,29 @@ export default async function sessionRoutes(app) {
|
|
|
70
117
|
dir,
|
|
71
118
|
attached: false,
|
|
72
119
|
status: "restorable",
|
|
120
|
+
since: null,
|
|
121
|
+
message: null,
|
|
122
|
+
// p10: no live pane to read a title from — restorable entries never
|
|
123
|
+
// have one (see session-status spec).
|
|
124
|
+
title: null,
|
|
73
125
|
restorable: true,
|
|
74
126
|
// No live pane to read a cwd from — resolve against the
|
|
75
127
|
// registered workspace dir instead; null if that's gone too.
|
|
76
128
|
branch: await getBranch(dir),
|
|
129
|
+
// p11: restorable entries never carry context — there's no live
|
|
130
|
+
// session to have posted a statusline or grown a fresh transcript
|
|
131
|
+
// usage figure since it died (see context-telemetry spec).
|
|
132
|
+
context: null,
|
|
77
133
|
});
|
|
78
134
|
}
|
|
79
135
|
|
|
80
136
|
return [...live, ...restorable];
|
|
81
137
|
});
|
|
82
138
|
|
|
139
|
+
// p11: account-wide rate limits from the most recent statusline post —
|
|
140
|
+
// null until one arrives. See statusline.js for the store.
|
|
141
|
+
app.get("/api/usage", async () => getRateLimits());
|
|
142
|
+
|
|
83
143
|
app.post("/api/sessions", async (req, reply) => {
|
|
84
144
|
const { workspace, label, worktree: wantWorktree } = req.body ?? {};
|
|
85
145
|
|
|
@@ -236,6 +296,29 @@ export default async function sessionRoutes(app) {
|
|
|
236
296
|
.code(403)
|
|
237
297
|
.send({ error: "refusing to touch non-garage sessions" });
|
|
238
298
|
}
|
|
299
|
+
|
|
300
|
+
// p8.1: `?meta=1` deletes ONLY the stored resume metadata of a NON-live
|
|
301
|
+
// (restorable) session — the plain DELETE below 404s for those since
|
|
302
|
+
// there is no tmux session to kill. Behavior without the param is
|
|
303
|
+
// unchanged (the web UI never sends it). The worktree record rides the
|
|
304
|
+
// response the same way, so the caller can still surface "worktree
|
|
305
|
+
// kept" for a discarded restorable worktree session.
|
|
306
|
+
if (req.query?.meta === "1") {
|
|
307
|
+
if (await hasSession(id)) {
|
|
308
|
+
return reply
|
|
309
|
+
.code(409)
|
|
310
|
+
.send({ error: `session is live — delete it without meta=1: ${id}` });
|
|
311
|
+
}
|
|
312
|
+
const meta = await getSessionMeta(id).catch(() => null);
|
|
313
|
+
if (!meta) {
|
|
314
|
+
return reply.code(404).send({ error: `no resume metadata for: ${id}` });
|
|
315
|
+
}
|
|
316
|
+
await removeSessionMeta(id).catch(() => {});
|
|
317
|
+
return reply
|
|
318
|
+
.code(200)
|
|
319
|
+
.send({ deleted: true, meta: true, worktree: meta?.worktree ?? null });
|
|
320
|
+
}
|
|
321
|
+
|
|
239
322
|
if (!(await hasSession(id))) {
|
|
240
323
|
return reply.code(404).send({ error: `no such session: ${id}` });
|
|
241
324
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { copyFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
// Shared by hooks.js and statusline.js — both merge a garage-owned fragment
|
|
5
|
+
// into ~/.claude/settings.json with the same safety order: parse-or-refuse
|
|
6
|
+
// (a corrupt file is reported as a 422, byte-for-byte untouched) -> caller
|
|
7
|
+
// merges its own fragment -> timestamped backup of the pre-write file ->
|
|
8
|
+
// atomic write (tmp + rename, same directory, so a crash mid-write can never
|
|
9
|
+
// leave a half-written settings.json).
|
|
10
|
+
|
|
11
|
+
// Read + parse settings.json. Returns `{settings, raw}`: `settings` is `{}`
|
|
12
|
+
// for a missing/empty file (nothing to refuse), the parsed object otherwise;
|
|
13
|
+
// `raw` is the exact original file text, or null if the file didn't exist
|
|
14
|
+
// (used by writeSettingsAtomic to decide whether a backup is needed).
|
|
15
|
+
// Throws a statusCode:422 error — file left completely untouched — for
|
|
16
|
+
// invalid JSON or a non-object top level.
|
|
17
|
+
export async function readSettingsOrRefuse(path) {
|
|
18
|
+
let raw = null;
|
|
19
|
+
try {
|
|
20
|
+
raw = await readFile(path, "utf8");
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (err.code !== "ENOENT") throw err;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let settings = {};
|
|
26
|
+
if (raw !== null && raw.trim() !== "") {
|
|
27
|
+
try {
|
|
28
|
+
settings = JSON.parse(raw);
|
|
29
|
+
} catch {
|
|
30
|
+
const error = new Error(
|
|
31
|
+
`${path} is not valid JSON — fix it (or remove it) and retry; the file was left untouched`
|
|
32
|
+
);
|
|
33
|
+
error.statusCode = 422;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
if (typeof settings !== "object" || settings === null || Array.isArray(settings)) {
|
|
37
|
+
const error = new Error(`${path} is not a JSON object — the file was left untouched`);
|
|
38
|
+
error.statusCode = 422;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { settings, raw };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Backs up (if `raw` is non-null, i.e. the file previously existed) then
|
|
46
|
+
// atomically writes `merged`. Returns the backup path, or null when there
|
|
47
|
+
// was nothing to back up.
|
|
48
|
+
export async function writeSettingsAtomic(path, raw, merged) {
|
|
49
|
+
await mkdir(dirname(path), { recursive: true });
|
|
50
|
+
|
|
51
|
+
let backup = null;
|
|
52
|
+
if (raw !== null) {
|
|
53
|
+
backup = `${path}.garage-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
54
|
+
await copyFile(path, backup);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const tmp = `${path}.garage-tmp-${process.pid}`;
|
|
58
|
+
await writeFile(tmp, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
|
59
|
+
await rename(tmp, path);
|
|
60
|
+
|
|
61
|
+
return backup;
|
|
62
|
+
}
|
package/daemon/src/status.js
CHANGED
|
@@ -2,58 +2,68 @@ import { EventEmitter } from "node:events";
|
|
|
2
2
|
|
|
3
3
|
// D-status: StatusStore. Four states: needs-input | working | done | idle.
|
|
4
4
|
// setStatus is the single write path — API, SSE, and notifications only
|
|
5
|
-
// ever read (getStatus) or subscribe (statusEvents).
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
5
|
+
// ever read (getStatus/getStatusEntry) or subscribe (statusEvents). There
|
|
6
|
+
// is NO auto-decay: `done` now holds until something else transitions it
|
|
7
|
+
// (poller sees 'busy', a hook fires, or dropSession removes the entry when
|
|
8
|
+
// the session dies). `needs-input` never auto-decayed either — same rule,
|
|
9
|
+
// it only clears via a new setStatus call. `since` records the epoch ms of
|
|
10
|
+
// the last actual state change (not touched by repeat setStatus calls with
|
|
11
|
+
// the same state), so the UI can render "how long in this state".
|
|
12
|
+
//
|
|
13
|
+
// p8 message capture: needs-input may carry the Notification hook's message
|
|
14
|
+
// text. Only hook-sourced writes pass one; the poller's coarse `waiting`
|
|
15
|
+
// observation passes none, and a messageless same-state write must not wipe
|
|
16
|
+
// a hook-set message (the 2s poller would otherwise erase it on the next
|
|
17
|
+
// tick). Any transition away from needs-input clears it.
|
|
10
18
|
|
|
11
|
-
const store = new Map(); // id -> { state, since }
|
|
12
|
-
const decayTimers = new Map(); // id -> Timeout
|
|
19
|
+
const store = new Map(); // id -> { state, since, message }
|
|
13
20
|
|
|
14
21
|
export const statusEvents = new EventEmitter();
|
|
15
22
|
statusEvents.setMaxListeners(0);
|
|
16
23
|
|
|
17
|
-
function clearDecay(id) {
|
|
18
|
-
const timer = decayTimers.get(id);
|
|
19
|
-
if (timer) {
|
|
20
|
-
clearTimeout(timer);
|
|
21
|
-
decayTimers.delete(id);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
24
|
// Unknown/never-signaled sessions read as idle.
|
|
26
25
|
export function getStatus(id) {
|
|
27
26
|
return store.get(id)?.state ?? "idle";
|
|
28
27
|
}
|
|
29
28
|
|
|
29
|
+
// Same lookup as getStatus but also exposes `since` (epoch ms the current
|
|
30
|
+
// state began, or null for a never-signaled id) so callers can compute
|
|
31
|
+
// elapsed time without a second store.
|
|
32
|
+
export function getStatusEntry(id) {
|
|
33
|
+
return store.get(id) ?? { state: "idle", since: null, message: null };
|
|
34
|
+
}
|
|
35
|
+
|
|
30
36
|
export function getAllStatuses() {
|
|
31
37
|
return new Map(store);
|
|
32
38
|
}
|
|
33
39
|
|
|
34
|
-
export function setStatus(id, state) {
|
|
40
|
+
export function setStatus(id, state, message = undefined) {
|
|
35
41
|
const existing = store.get(id);
|
|
36
42
|
const prev = existing?.state ?? "idle";
|
|
37
43
|
const changed = prev !== state;
|
|
38
44
|
const since = changed ? Date.now() : existing?.since ?? Date.now();
|
|
39
45
|
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
// Only needs-input carries a message. A messageless needs-input write
|
|
47
|
+
// (the poller) preserves what a hook already captured on a same-state
|
|
48
|
+
// tick, but never invents one on a fresh transition.
|
|
49
|
+
const nextMessage =
|
|
50
|
+
state !== "needs-input"
|
|
51
|
+
? null
|
|
52
|
+
: message !== undefined
|
|
53
|
+
? message
|
|
54
|
+
: prev === "needs-input"
|
|
55
|
+
? existing?.message ?? null
|
|
56
|
+
: null;
|
|
42
57
|
|
|
43
|
-
|
|
44
|
-
const timer = setTimeout(() => setStatus(id, "idle"), DECAY_MS);
|
|
45
|
-
timer.unref?.();
|
|
46
|
-
decayTimers.set(id, timer);
|
|
47
|
-
}
|
|
58
|
+
store.set(id, { state, since, message: nextMessage });
|
|
48
59
|
|
|
49
60
|
if (changed) {
|
|
50
61
|
statusEvents.emit("transition", { id, from: prev, to: state });
|
|
51
62
|
}
|
|
52
63
|
}
|
|
53
64
|
|
|
54
|
-
// Called when a session disappears from tmux (poller-detected death) so
|
|
55
|
-
//
|
|
65
|
+
// Called when a session disappears from tmux (poller-detected death) so a
|
|
66
|
+
// future reuse of the same id starts clean.
|
|
56
67
|
export function dropSession(id) {
|
|
57
|
-
clearDecay(id);
|
|
58
68
|
store.delete(id);
|
|
59
69
|
}
|