uv-suite 0.27.0 → 0.28.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.
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# UV Suite helper: explicit session termination.
|
|
3
|
+
# Used by the /session-end slash command.
|
|
4
|
+
#
|
|
5
|
+
# Writes a "terminated_at" timestamp to the session metadata, fires a
|
|
6
|
+
# SessionEnd event to the Watchtower so the dashboard updates the status
|
|
7
|
+
# badge, and prints a one-line confirmation.
|
|
8
|
+
|
|
9
|
+
STATE_DIR="${CLAUDE_PROJECT_DIR:-.}/.uv-suite-state"
|
|
10
|
+
SESSIONS_DIR="$STATE_DIR/sessions"
|
|
11
|
+
mkdir -p "$SESSIONS_DIR"
|
|
12
|
+
|
|
13
|
+
SID="${UVS_SESSION_ID:-}"
|
|
14
|
+
if [ -z "$SID" ] && [ -f "$STATE_DIR/current-session.txt" ]; then
|
|
15
|
+
SID=$(cat "$STATE_DIR/current-session.txt" 2>/dev/null)
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
if [ -z "$SID" ]; then
|
|
19
|
+
echo "No active UV Suite session to end."
|
|
20
|
+
exit 1
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
META="$SESSIONS_DIR/$SID.json"
|
|
24
|
+
NOW=$(date +%s)
|
|
25
|
+
NOW_ISO=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
26
|
+
|
|
27
|
+
if [ -f "$META" ]; then
|
|
28
|
+
META_PATH="$META" NOW_VAL="$NOW" NOW_ISO_VAL="$NOW_ISO" python3 - <<'PY'
|
|
29
|
+
import json, os
|
|
30
|
+
p = os.environ["META_PATH"]
|
|
31
|
+
d = json.load(open(p))
|
|
32
|
+
d["terminated_at"] = int(os.environ["NOW_VAL"])
|
|
33
|
+
d["terminated_at_iso"] = os.environ["NOW_ISO_VAL"]
|
|
34
|
+
d["lifecycle"] = "terminated"
|
|
35
|
+
json.dump(d, open(p, "w"), indent=2)
|
|
36
|
+
PY
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
# Fire SessionEnd event so the dashboard flips the status badge to Terminated.
|
|
40
|
+
if [ -x "$CLAUDE_PROJECT_DIR/.claude/hooks/watchtower-send.sh" ]; then
|
|
41
|
+
PAYLOAD=$(SID_VAL="$SID" NOW_VAL="$NOW" CWD_VAL="${CLAUDE_PROJECT_DIR:-$(pwd)}" python3 -c '
|
|
42
|
+
import json, os
|
|
43
|
+
print(json.dumps({
|
|
44
|
+
"uvs_session_id": os.environ["SID_VAL"],
|
|
45
|
+
"session_id": os.environ["SID_VAL"],
|
|
46
|
+
"cwd": os.environ["CWD_VAL"],
|
|
47
|
+
"lifecycle": "terminated",
|
|
48
|
+
"terminated_at": int(os.environ["NOW_VAL"]),
|
|
49
|
+
"terminated_by": "user",
|
|
50
|
+
}))
|
|
51
|
+
')
|
|
52
|
+
printf '%s' "$PAYLOAD" | "$CLAUDE_PROJECT_DIR/.claude/hooks/watchtower-send.sh" SessionEnd 2>/dev/null
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
echo "Session ${SID:0:8} marked terminated at $NOW_ISO."
|
|
56
|
+
echo "Run /checkpoint first if you want a final state snapshot, then exit the terminal."
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uv-suite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Portable framework for AI-assisted software development. 10 agents, 9 skills, 5 hooks, 4 personas. Works with Claude Code, Cursor, and Codex.",
|
|
5
5
|
"author": "Utsav Anand",
|
|
6
6
|
"license": "MIT",
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: session-end
|
|
3
|
+
description: >
|
|
4
|
+
Cleanly close the current UV Suite session: write a final manual checkpoint
|
|
5
|
+
(full conversation context), then mark the session as terminated so the
|
|
6
|
+
Watchtower dashboard flips its lifecycle badge to Terminated. Use before
|
|
7
|
+
closing the terminal when you want a deliberate end-of-session beat.
|
|
8
|
+
argument-hint: "[optional-label]"
|
|
9
|
+
user-invocable: true
|
|
10
|
+
allowed-tools:
|
|
11
|
+
- Write(*)
|
|
12
|
+
- Read(*)
|
|
13
|
+
- Bash(git status *)
|
|
14
|
+
- Bash(git diff *)
|
|
15
|
+
- Bash(git log *)
|
|
16
|
+
- Bash(git branch *)
|
|
17
|
+
- Bash(git rev-parse *)
|
|
18
|
+
- Bash(date *)
|
|
19
|
+
- Bash(ls *)
|
|
20
|
+
- Bash(mkdir *)
|
|
21
|
+
- Bash(cat *)
|
|
22
|
+
- Bash(echo *)
|
|
23
|
+
- Bash("$CLAUDE_PROJECT_DIR"/.claude/hooks/checkpoint-helper.sh *)
|
|
24
|
+
- Bash("$CLAUDE_PROJECT_DIR"/.claude/hooks/session-end-helper.sh *)
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Resolve checkpoint directory + session metadata
|
|
28
|
+
|
|
29
|
+
!`"$CLAUDE_PROJECT_DIR"/.claude/hooks/checkpoint-helper.sh dir`
|
|
30
|
+
|
|
31
|
+
!`"$CLAUDE_PROJECT_DIR"/.claude/hooks/checkpoint-helper.sh frontmatter`
|
|
32
|
+
|
|
33
|
+
## Git state
|
|
34
|
+
|
|
35
|
+
!`git branch --show-current 2>/dev/null || echo "(not a git repo)"`
|
|
36
|
+
|
|
37
|
+
!`git status --short 2>/dev/null | head -20 || echo "(no git)"`
|
|
38
|
+
|
|
39
|
+
!`git log --oneline -5 2>/dev/null || echo "(no git history)"`
|
|
40
|
+
|
|
41
|
+
## Step 1 — Write the final checkpoint
|
|
42
|
+
|
|
43
|
+
Write a file at `<checkpoint-dir>/final-YYYY-MM-DD-HHMM.md` using the current
|
|
44
|
+
timestamp. **Override** the frontmatter line `checkpoint_kind: auto-mechanical`
|
|
45
|
+
to read `checkpoint_kind: final-manual` so it's distinguishable from the
|
|
46
|
+
auto-checkpoints. Also write/overwrite `<checkpoint-dir>/latest.md` with the
|
|
47
|
+
same content so `/restore` picks it up.
|
|
48
|
+
|
|
49
|
+
If a label was provided, include it in the filename:
|
|
50
|
+
`<checkpoint-dir>/final-YYYY-MM-DD-HHMM-[label].md`
|
|
51
|
+
|
|
52
|
+
### Label
|
|
53
|
+
|
|
54
|
+
$ARGUMENTS
|
|
55
|
+
|
|
56
|
+
### Body structure (after the frontmatter)
|
|
57
|
+
|
|
58
|
+
```markdown
|
|
59
|
+
# Final checkpoint: [date] [time] [label if provided]
|
|
60
|
+
|
|
61
|
+
## What was accomplished
|
|
62
|
+
- Concrete things done across the whole session — files, commits, decisions
|
|
63
|
+
- One bullet per significant artifact; don't pad
|
|
64
|
+
|
|
65
|
+
## Key decisions made
|
|
66
|
+
- Decision: Why — what was considered, what was rejected
|
|
67
|
+
|
|
68
|
+
## Current state
|
|
69
|
+
- Branch / uncommitted changes / tests status / blockers
|
|
70
|
+
|
|
71
|
+
## Open threads
|
|
72
|
+
- Anything left in flight that the next session needs to pick up
|
|
73
|
+
- Pending reviews, awaiting input, deferred work
|
|
74
|
+
|
|
75
|
+
## Context the next session needs
|
|
76
|
+
- Non-obvious facts, workarounds, "this looks wrong but it's intentional because…"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Be specific. This is the last record before the session closes — anything
|
|
80
|
+
not written here is lost unless it's in code or the auto-checkpoints.
|
|
81
|
+
|
|
82
|
+
## Step 2 — Mark the session terminated
|
|
83
|
+
|
|
84
|
+
Once the file is written, run:
|
|
85
|
+
|
|
86
|
+
!`"$CLAUDE_PROJECT_DIR"/.claude/hooks/session-end-helper.sh`
|
|
87
|
+
|
|
88
|
+
Show the user the two-line output. Don't add commentary — the file write
|
|
89
|
+
and the termination signal together speak for themselves.
|
|
90
|
+
|
|
91
|
+
## Notes
|
|
92
|
+
|
|
93
|
+
- `/session-end` does **not** close the terminal or exit Claude Code. After
|
|
94
|
+
it runs, the badge flips to Terminated in the Watchtower; the user
|
|
95
|
+
closes the terminal manually.
|
|
96
|
+
- The final checkpoint uses the live session's full conversation context —
|
|
97
|
+
higher fidelity than the auto-checkpoint summaries because Claude has
|
|
98
|
+
everything in working memory at this moment.
|
|
99
|
+
- If you only want to terminate without a checkpoint, run
|
|
100
|
+
`hooks/session-end-helper.sh` directly from bash and skip this skill.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
You are writing
|
|
2
|
-
|
|
3
|
-
what
|
|
1
|
+
You are writing a one-paragraph summary for the UV Suite auto-checkpoint
|
|
2
|
+
system. The actual conversation transcript is provided below — base the
|
|
3
|
+
summary on what was actually said and done, not on inference.
|
|
4
4
|
|
|
5
5
|
## Session
|
|
6
6
|
|
|
@@ -11,41 +11,32 @@ what the events show.
|
|
|
11
11
|
- purpose: {{purpose}}
|
|
12
12
|
- elapsed since last semantic checkpoint: {{elapsed_min}} min
|
|
13
13
|
|
|
14
|
-
##
|
|
14
|
+
## Conversation (verbatim, last {{interval_min}} min)
|
|
15
15
|
|
|
16
|
-
{{
|
|
16
|
+
{{conversation}}
|
|
17
17
|
|
|
18
|
-
##
|
|
19
|
-
|
|
20
|
-
```
|
|
21
|
-
{{git_branch}}
|
|
22
|
-
{{git_status}}
|
|
23
|
-
{{git_log}}
|
|
24
|
-
```
|
|
18
|
+
## Mechanical (from the dashboard event log)
|
|
25
19
|
|
|
26
|
-
|
|
20
|
+
{{mechanical}}
|
|
27
21
|
|
|
28
|
-
|
|
29
|
-
Use this exact shape, max 30 lines total:
|
|
30
|
-
|
|
31
|
-
```markdown
|
|
32
|
-
# Auto-checkpoint: {{timestamp}}
|
|
22
|
+
## Git
|
|
33
23
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
24
|
+
Branch: {{git_branch}}
|
|
25
|
+
Status: {{git_status}}
|
|
26
|
+
Recent commits: {{git_log}}
|
|
37
27
|
|
|
38
|
-
##
|
|
39
|
-
- list (omit section if empty)
|
|
28
|
+
## Write the summary
|
|
40
29
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
on the latest events.
|
|
30
|
+
Output **only** a single paragraph, 3-6 sentences, no headers, no bullets.
|
|
31
|
+
The paragraph should answer, in order:
|
|
44
32
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
```
|
|
33
|
+
1. What was the user trying to do this window?
|
|
34
|
+
2. What did Claude actually do — concrete files / commands / decisions?
|
|
35
|
+
3. Where does the session stand right now (in progress / blocked / awaiting input)?
|
|
49
36
|
|
|
50
|
-
|
|
51
|
-
|
|
37
|
+
Rules:
|
|
38
|
+
- Use the user's own words for the topic when possible ("user asked about X")
|
|
39
|
+
- Name the artifacts (file paths, command names, function names) — no vague verbs
|
|
40
|
+
- If the conversation is sparse, say "Quiet window — only X tool calls, no
|
|
41
|
+
substantive exchange" and stop. Don't invent activity.
|
|
42
|
+
- Don't restate the session metadata; it's already in the frontmatter.
|
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
// UV Suite — Tier B auto-checkpoint runner.
|
|
2
2
|
// Called from watchtower/server.js on a setInterval. For each active session
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// whose interval has elapsed, reads the Claude Code transcript JSONL at
|
|
4
|
+
// ~/.claude/projects/<encoded-cwd>/<session_id>.jsonl, extracts the
|
|
5
|
+
// conversation in the window, and writes a self-contained checkpoint:
|
|
6
|
+
//
|
|
7
|
+
// ## Summary — one paragraph from `claude -p --bare --model haiku`
|
|
8
|
+
// using the transcript as input
|
|
9
|
+
// ## Conversation — raw extract: user prompts verbatim + assistant
|
|
10
|
+
// response openings (~250 chars each) + tool calls
|
|
11
|
+
// ## Mechanical — git state + tool counts + files touched
|
|
12
|
+
//
|
|
13
|
+
// The transcript is copied into our checkpoint, so the file stands alone
|
|
14
|
+
// even if Claude Code later deletes its source JSONL.
|
|
5
15
|
|
|
6
16
|
const fs = require("fs");
|
|
7
17
|
const path = require("path");
|
|
18
|
+
const os = require("os");
|
|
8
19
|
const { spawn } = require("child_process");
|
|
9
20
|
|
|
10
21
|
const PROMPT_TEMPLATE_PATH = path.join(__dirname, "auto-checkpoint-prompt.md");
|
|
11
22
|
const DEFAULT_INTERVAL_MIN = 10;
|
|
12
|
-
const POLL_INTERVAL_MS = 60 * 1000;
|
|
23
|
+
const POLL_INTERVAL_MS = 60 * 1000;
|
|
13
24
|
const MAX_BUDGET_USD = "0.05";
|
|
14
25
|
const MODEL = "haiku";
|
|
26
|
+
const MAX_ASSISTANT_PREVIEW_CHARS = 250;
|
|
27
|
+
const MAX_CONVERSATION_LINES = 200;
|
|
15
28
|
|
|
16
29
|
function readJsonSafe(p) {
|
|
17
30
|
try {
|
|
@@ -64,6 +77,136 @@ function groupActiveSessions(events, windowMs) {
|
|
|
64
77
|
return [...bySession.values()];
|
|
65
78
|
}
|
|
66
79
|
|
|
80
|
+
// Claude Code stores transcripts at ~/.claude/projects/<encoded>/<sid>.jsonl
|
|
81
|
+
// where <encoded> is the project path with "/" replaced by "-".
|
|
82
|
+
function transcriptPathFor(cwd, ccSessionId) {
|
|
83
|
+
if (!ccSessionId) return null;
|
|
84
|
+
const encoded = cwd.replace(/\//g, "-");
|
|
85
|
+
return path.join(
|
|
86
|
+
os.homedir(),
|
|
87
|
+
".claude",
|
|
88
|
+
"projects",
|
|
89
|
+
encoded,
|
|
90
|
+
`${ccSessionId}.jsonl`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Defensive parser: Claude Code's JSONL format is internal and may change.
|
|
95
|
+
// We pull out user prompts, assistant responses, and tool calls — skipping
|
|
96
|
+
// anything we can't interpret rather than blowing up.
|
|
97
|
+
function extractTextFromContent(content) {
|
|
98
|
+
if (typeof content === "string") return content;
|
|
99
|
+
if (Array.isArray(content)) {
|
|
100
|
+
return content
|
|
101
|
+
.map((block) => {
|
|
102
|
+
if (typeof block === "string") return block;
|
|
103
|
+
if (block && block.type === "text" && typeof block.text === "string")
|
|
104
|
+
return block.text;
|
|
105
|
+
return "";
|
|
106
|
+
})
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.join("\n");
|
|
109
|
+
}
|
|
110
|
+
return "";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Read transcript messages whose timestamp falls in [sinceMs, +inf).
|
|
114
|
+
// Returns a flat array of { role, text, ts, tool? } records, oldest first.
|
|
115
|
+
function readTranscriptMessages(transcriptPath, sinceMs) {
|
|
116
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
|
117
|
+
if (transcriptPath) {
|
|
118
|
+
console.warn(`[auto-checkpoint] transcript not found: ${transcriptPath}`);
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
let raw;
|
|
123
|
+
try {
|
|
124
|
+
raw = fs.readFileSync(transcriptPath, "utf-8");
|
|
125
|
+
} catch (err) {
|
|
126
|
+
console.warn(`[auto-checkpoint] failed to read transcript: ${err.message}`);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const out = [];
|
|
130
|
+
let totalLines = 0;
|
|
131
|
+
let parseFailures = 0;
|
|
132
|
+
let recognizedShapes = 0;
|
|
133
|
+
for (const line of raw.split("\n")) {
|
|
134
|
+
if (!line.trim()) continue;
|
|
135
|
+
totalLines++;
|
|
136
|
+
let msg;
|
|
137
|
+
try {
|
|
138
|
+
msg = JSON.parse(line);
|
|
139
|
+
} catch {
|
|
140
|
+
parseFailures++;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const tsStr = msg.timestamp || msg.ts || msg.createdAt || msg.created_at;
|
|
144
|
+
const ts = tsStr ? Date.parse(tsStr) : NaN;
|
|
145
|
+
if (Number.isFinite(ts) && ts < sinceMs) continue;
|
|
146
|
+
|
|
147
|
+
// Tolerate several shapes Claude Code has used:
|
|
148
|
+
// { type: "user"|"assistant", message: { role, content } }
|
|
149
|
+
// { role, content }
|
|
150
|
+
// { sender: "user"|"assistant", text } (older)
|
|
151
|
+
// { event: "message", role, content } (variant)
|
|
152
|
+
const role =
|
|
153
|
+
msg.role ||
|
|
154
|
+
msg.message?.role ||
|
|
155
|
+
msg.sender ||
|
|
156
|
+
(msg.type === "user" || msg.type === "assistant" ? msg.type : null);
|
|
157
|
+
const content = msg.message?.content ?? msg.content ?? msg.text ?? msg.body;
|
|
158
|
+
const text = extractTextFromContent(content).trim();
|
|
159
|
+
|
|
160
|
+
if (role === "user" && text) {
|
|
161
|
+
out.push({ role: "user", text, ts });
|
|
162
|
+
recognizedShapes++;
|
|
163
|
+
} else if (role === "assistant" && text) {
|
|
164
|
+
out.push({ role: "assistant", text, ts });
|
|
165
|
+
recognizedShapes++;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Surface format drift loudly: if the file has content but nothing parsed
|
|
169
|
+
// as a recognizable message, the Claude Code schema has probably changed.
|
|
170
|
+
if (totalLines > 0 && recognizedShapes === 0) {
|
|
171
|
+
console.warn(
|
|
172
|
+
`[auto-checkpoint] transcript at ${transcriptPath}: ${totalLines} lines, ` +
|
|
173
|
+
`${parseFailures} JSON-parse failures, 0 recognized user/assistant messages. ` +
|
|
174
|
+
`Claude Code's transcript format may have changed — please file an issue.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Build the ## Conversation extract markdown block. Trims long assistant
|
|
181
|
+
// turns; caps total lines.
|
|
182
|
+
function buildConversationExtract(messages) {
|
|
183
|
+
if (!messages || messages.length === 0) return "";
|
|
184
|
+
const lines = [];
|
|
185
|
+
for (const m of messages) {
|
|
186
|
+
if (m.role === "user") {
|
|
187
|
+
lines.push(`**You:**`);
|
|
188
|
+
for (const ln of m.text.split("\n")) lines.push(`> ${ln}`);
|
|
189
|
+
} else {
|
|
190
|
+
let preview = m.text;
|
|
191
|
+
if (preview.length > MAX_ASSISTANT_PREVIEW_CHARS) {
|
|
192
|
+
preview =
|
|
193
|
+
preview.slice(0, MAX_ASSISTANT_PREVIEW_CHARS).trimEnd() + " …";
|
|
194
|
+
}
|
|
195
|
+
lines.push(`**Claude:** ${preview.replace(/\n+/g, " ")}`);
|
|
196
|
+
}
|
|
197
|
+
lines.push("");
|
|
198
|
+
}
|
|
199
|
+
if (lines.length > MAX_CONVERSATION_LINES) {
|
|
200
|
+
const trimmed = lines.slice(-MAX_CONVERSATION_LINES);
|
|
201
|
+
trimmed.unshift(
|
|
202
|
+
`_(earlier turns truncated; showing last ${MAX_CONVERSATION_LINES} lines)_`,
|
|
203
|
+
"",
|
|
204
|
+
);
|
|
205
|
+
return trimmed.join("\n");
|
|
206
|
+
}
|
|
207
|
+
return lines.join("\n");
|
|
208
|
+
}
|
|
209
|
+
|
|
67
210
|
function eventToCompactLine(ev) {
|
|
68
211
|
const t = ev.event_type || ev.hook_event_name || "?";
|
|
69
212
|
const tool = ev.tool_name || "";
|
|
@@ -170,41 +313,94 @@ async function processSession(session, broadcast) {
|
|
|
170
313
|
.sort((a, b) => (a._ts || 0) - (b._ts || 0));
|
|
171
314
|
if (recent.length === 0) return;
|
|
172
315
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
316
|
+
// Find the Claude Code session id from the most recent event (it differs
|
|
317
|
+
// from uvs_session_id) and read the conversation transcript.
|
|
318
|
+
const ccSessionId =
|
|
319
|
+
[...recent].reverse().find((e) => e.session_id)?.session_id || null;
|
|
320
|
+
const transcriptPath = transcriptPathFor(cwd, ccSessionId);
|
|
321
|
+
const sinceMs = lastTs > 0 ? lastTs * 1000 : now - intervalMs;
|
|
322
|
+
const transcriptMessages = readTranscriptMessages(transcriptPath, sinceMs);
|
|
323
|
+
|
|
324
|
+
const conversationExtract =
|
|
325
|
+
buildConversationExtract(transcriptMessages) ||
|
|
326
|
+
"_(no transcript content found; only mechanical activity captured below)_";
|
|
327
|
+
|
|
328
|
+
// Mechanical breakdown — tool counts and files touched.
|
|
329
|
+
const toolCounts = {};
|
|
330
|
+
const fileCounts = {};
|
|
331
|
+
for (const e of recent) {
|
|
332
|
+
const t = e.tool_name;
|
|
333
|
+
if (t) toolCounts[t] = (toolCounts[t] || 0) + 1;
|
|
334
|
+
const fp = e.tool_input?.file_path;
|
|
335
|
+
if (fp && (t === "Edit" || t === "Write" || t === "Read")) {
|
|
336
|
+
fileCounts[fp] = (fileCounts[fp] || 0) + 1;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const mechanicalLines = [];
|
|
340
|
+
mechanicalLines.push("### Tool calls");
|
|
341
|
+
Object.entries(toolCounts)
|
|
342
|
+
.sort((a, b) => b[1] - a[1])
|
|
343
|
+
.slice(0, 8)
|
|
344
|
+
.forEach(([t, n]) => mechanicalLines.push(`- ${n}× ${t}`));
|
|
345
|
+
if (Object.keys(fileCounts).length) {
|
|
346
|
+
mechanicalLines.push("", "### Files touched");
|
|
347
|
+
Object.entries(fileCounts)
|
|
348
|
+
.sort((a, b) => b[1] - a[1])
|
|
349
|
+
.slice(0, 8)
|
|
350
|
+
.forEach(([f, n]) => mechanicalLines.push(`- ${f} (${n})`));
|
|
177
351
|
}
|
|
352
|
+
const mechanicalBlock = mechanicalLines.join("\n");
|
|
178
353
|
|
|
179
354
|
const git = await gitState(cwd);
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
?
|
|
184
|
-
:
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
priority: session.session_priority || "(unset)",
|
|
190
|
-
persona: session.persona || "(unset)",
|
|
191
|
-
purpose: session.session_purpose || "(unset)",
|
|
192
|
-
elapsed_min: String(elapsedMin),
|
|
193
|
-
interval_min: String(state.interval_minutes),
|
|
194
|
-
event_list: eventList,
|
|
195
|
-
git_branch: git.branch ? `Branch: ${git.branch}` : "(not a git repo)",
|
|
196
|
-
git_status: git.status || "(no changes)",
|
|
197
|
-
git_log: git.log || "",
|
|
198
|
-
timestamp: new Date(now).toISOString(),
|
|
199
|
-
});
|
|
355
|
+
const gitBlock = [
|
|
356
|
+
git.branch ? `**Branch:** ${git.branch}` : "_(not a git repo)_",
|
|
357
|
+
git.status
|
|
358
|
+
? "**Status:**\n```\n" + git.status + "\n```"
|
|
359
|
+
: "**Status:** clean",
|
|
360
|
+
git.log ? "**Recent commits:**\n```\n" + git.log + "\n```" : "",
|
|
361
|
+
]
|
|
362
|
+
.filter(Boolean)
|
|
363
|
+
.join("\n\n");
|
|
200
364
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
365
|
+
// Summary via claude -p, fed the actual conversation extract instead of
|
|
366
|
+
// just the event log. Falls back to a one-line stub if the call fails or
|
|
367
|
+
// the transcript is empty.
|
|
368
|
+
let summary = "";
|
|
369
|
+
const template = loadPromptTemplate();
|
|
370
|
+
if (template && transcriptMessages && transcriptMessages.length > 0) {
|
|
371
|
+
const elapsedMin =
|
|
372
|
+
lastTs === 0
|
|
373
|
+
? state.interval_minutes
|
|
374
|
+
: Math.round((now - lastTs * 1000) / 60000);
|
|
375
|
+
const prompt = buildPrompt(template, {
|
|
376
|
+
name: session.session_name || "(unset)",
|
|
377
|
+
kind: session.session_kind || "(unset)",
|
|
378
|
+
priority: session.session_priority || "(unset)",
|
|
379
|
+
persona: session.persona || "(unset)",
|
|
380
|
+
purpose: session.session_purpose || "(unset)",
|
|
381
|
+
elapsed_min: String(elapsedMin),
|
|
382
|
+
interval_min: String(state.interval_minutes),
|
|
383
|
+
conversation: conversationExtract,
|
|
384
|
+
mechanical: mechanicalBlock,
|
|
385
|
+
git_branch: git.branch || "(not a git repo)",
|
|
386
|
+
git_status: git.status || "(no changes)",
|
|
387
|
+
git_log: git.log || "",
|
|
388
|
+
timestamp: new Date(now).toISOString(),
|
|
389
|
+
});
|
|
390
|
+
const result = await runClaudeP(prompt);
|
|
391
|
+
if (result.ok && result.stdout.trim()) {
|
|
392
|
+
summary = result.stdout.trim();
|
|
393
|
+
} else {
|
|
394
|
+
console.warn(
|
|
395
|
+
`[auto-checkpoint] summary call failed for ${sid.slice(0, 8)}:`,
|
|
396
|
+
result.error || result.stderr?.slice(0, 200) || `exit ${result.code}`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (!summary) {
|
|
401
|
+
summary = transcriptMessages
|
|
402
|
+
? "_(summary generation failed; raw conversation below)_"
|
|
403
|
+
: "_(no conversation transcript available; only mechanical activity below)_";
|
|
208
404
|
}
|
|
209
405
|
|
|
210
406
|
// Write the checkpoint file
|
|
@@ -227,15 +423,37 @@ async function processSession(session, broadcast) {
|
|
|
227
423
|
`persona: ${session.persona || ""}`,
|
|
228
424
|
`checkpoint_at: ${new Date(now).toISOString()}`,
|
|
229
425
|
`checkpoint_kind: auto-semantic`,
|
|
426
|
+
`transcript_messages: ${transcriptMessages ? transcriptMessages.length : 0}`,
|
|
427
|
+
`tool_calls_in_window: ${recent.length}`,
|
|
230
428
|
"---",
|
|
231
429
|
"",
|
|
232
430
|
].join("\n");
|
|
233
431
|
|
|
234
|
-
|
|
432
|
+
const body = [
|
|
433
|
+
`# Auto-checkpoint (semantic): ${new Date(now).toISOString()}`,
|
|
434
|
+
"",
|
|
435
|
+
"## Summary",
|
|
436
|
+
"",
|
|
437
|
+
summary,
|
|
438
|
+
"",
|
|
439
|
+
"## Conversation",
|
|
440
|
+
"",
|
|
441
|
+
conversationExtract,
|
|
442
|
+
"",
|
|
443
|
+
"## Mechanical",
|
|
444
|
+
"",
|
|
445
|
+
mechanicalBlock,
|
|
446
|
+
"",
|
|
447
|
+
"## Git",
|
|
448
|
+
"",
|
|
449
|
+
gitBlock,
|
|
450
|
+
"",
|
|
451
|
+
].join("\n");
|
|
452
|
+
|
|
453
|
+
fs.writeFileSync(cpFile, frontmatter + body);
|
|
235
454
|
fs.writeFileSync(lastFile, String(Math.floor(now / 1000)));
|
|
236
455
|
|
|
237
|
-
// Broadcast
|
|
238
|
-
const preview = (frontmatter + result.stdout).slice(0, 2000);
|
|
456
|
+
// Broadcast — the dashboard's expand-on-click body uses the summary.
|
|
239
457
|
const event = {
|
|
240
458
|
event_type: "AutoCheckpoint",
|
|
241
459
|
source_app: path.basename(cwd),
|
|
@@ -248,9 +466,11 @@ async function processSession(session, broadcast) {
|
|
|
248
466
|
persona: session.persona,
|
|
249
467
|
checkpoint_kind: "auto-semantic",
|
|
250
468
|
checkpoint_path: cpFile,
|
|
251
|
-
|
|
469
|
+
checkpoint_summary: summary,
|
|
470
|
+
checkpoint_preview: (frontmatter + body).slice(0, 2000),
|
|
252
471
|
interval_minutes: state.interval_minutes,
|
|
253
472
|
tool_calls_in_window: recent.length,
|
|
473
|
+
transcript_messages: transcriptMessages ? transcriptMessages.length : 0,
|
|
254
474
|
_ts: now,
|
|
255
475
|
};
|
|
256
476
|
broadcast(event);
|
|
@@ -305,7 +305,12 @@
|
|
|
305
305
|
.pill.priority-high { background: rgba(255, 69, 58, 0.18); color: var(--danger); }
|
|
306
306
|
.pill.kind-long-running { background: rgba(100, 210, 255, 0.18); color: var(--info); }
|
|
307
307
|
.pill.kind-outcome { background: rgba(255, 105, 97, 0.18); color: var(--peach); }
|
|
308
|
+
.pill.lifecycle-active { background: rgba(48, 209, 88, 0.18); color: var(--success); }
|
|
309
|
+
.pill.lifecycle-idle { background: rgba(154, 154, 163, 0.18); color: var(--text-muted); }
|
|
310
|
+
.pill.lifecycle-terminated { background: rgba(255, 69, 58, 0.16); color: var(--danger); }
|
|
308
311
|
|
|
312
|
+
.session-tag.lifecycle-terminated { opacity: 0.55; }
|
|
313
|
+
.session-tag.lifecycle-terminated strong { text-decoration: line-through; }
|
|
309
314
|
.session-tag.priority-low { opacity: 0.6; }
|
|
310
315
|
.session-tag .meta-line {
|
|
311
316
|
display: block;
|
|
@@ -487,9 +492,10 @@ function sessionColor(id) {
|
|
|
487
492
|
if (!sessions[id]) {
|
|
488
493
|
sessions[id] = {
|
|
489
494
|
color: palette[colorIdx++ % palette.length],
|
|
490
|
-
count: 0, lastEvent: null,
|
|
495
|
+
count: 0, lastEvent: null, lastEventTs: 0,
|
|
491
496
|
name: '', kind: '', purpose: '', priority: '', persona: '',
|
|
492
497
|
app: null, label: null,
|
|
498
|
+
terminated: false, terminatedAt: 0,
|
|
493
499
|
};
|
|
494
500
|
updateSessionBar();
|
|
495
501
|
updateFilterSession();
|
|
@@ -505,6 +511,7 @@ function updateSessionLabel(sid, ev) {
|
|
|
505
511
|
let changed = false;
|
|
506
512
|
|
|
507
513
|
if (!s.app && ev.source_app) { s.app = ev.source_app; changed = true; }
|
|
514
|
+
if (ev._ts && ev._ts > (s.lastEventTs || 0)) s.lastEventTs = ev._ts;
|
|
508
515
|
|
|
509
516
|
// Configured metadata wins over heuristics. Update on every event so a
|
|
510
517
|
// mid-session /session-init relabel is reflected without a refresh.
|
|
@@ -519,8 +526,17 @@ function updateSessionLabel(sid, ev) {
|
|
|
519
526
|
}
|
|
520
527
|
}
|
|
521
528
|
|
|
522
|
-
//
|
|
529
|
+
// Lifecycle: a session is Terminated when we receive Stop / SessionEnd
|
|
530
|
+
// (Claude Code's natural exit signal) OR when ev.lifecycle === 'terminated'
|
|
531
|
+
// (the /session-end slash command). Time-idleness alone does NOT terminate.
|
|
523
532
|
const type = ev.event_type || ev.hook_event_name || '';
|
|
533
|
+
if (!s.terminated && (type === 'Stop' || type === 'SessionEnd' || ev.lifecycle === 'terminated')) {
|
|
534
|
+
s.terminated = true;
|
|
535
|
+
s.terminatedAt = ev._ts || Date.now();
|
|
536
|
+
changed = true;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Fall back to first UserPromptSubmit if no configured name yet
|
|
524
540
|
if (!s.name && !s.label && type === 'UserPromptSubmit') {
|
|
525
541
|
const prompt = ev.tool_input?.prompt || ev.tool_input?.content || ev.message || '';
|
|
526
542
|
if (prompt.length > 0) {
|
|
@@ -537,6 +553,16 @@ function updateSessionLabel(sid, ev) {
|
|
|
537
553
|
}
|
|
538
554
|
}
|
|
539
555
|
|
|
556
|
+
// Active = event in last ACTIVE_WINDOW_MS; Terminated = explicit signal seen;
|
|
557
|
+
// Idle = anything else (including long-running sessions sitting dormant).
|
|
558
|
+
const ACTIVE_WINDOW_MS = 5 * 60 * 1000;
|
|
559
|
+
function sessionStatus(s) {
|
|
560
|
+
if (!s) return 'idle';
|
|
561
|
+
if (s.terminated) return 'terminated';
|
|
562
|
+
if ((Date.now() - (s.lastEventTs || 0)) <= ACTIVE_WINDOW_MS) return 'active';
|
|
563
|
+
return 'idle';
|
|
564
|
+
}
|
|
565
|
+
|
|
540
566
|
function sessionDisplayName(id) {
|
|
541
567
|
const s = sessions[id];
|
|
542
568
|
if (!s) return shortId(id);
|
|
@@ -744,6 +770,10 @@ setInterval(() => {
|
|
|
744
770
|
if (events.length > 0) updateWaitingText(events[events.length - 1]);
|
|
745
771
|
}, 5000);
|
|
746
772
|
|
|
773
|
+
// Refresh the session bar periodically so Active→Idle transitions show up
|
|
774
|
+
// without requiring a new event to arrive.
|
|
775
|
+
setInterval(updateSessionBar, 30000);
|
|
776
|
+
|
|
747
777
|
function updateStats() {
|
|
748
778
|
document.getElementById('sessionCount').textContent = Object.keys(sessions).length;
|
|
749
779
|
document.getElementById('eventCount').textContent = events.length;
|
|
@@ -771,11 +801,18 @@ function pill(cls, label) {
|
|
|
771
801
|
return `<span class="pill ${cls}">${escapeHtml(label)}</span>`;
|
|
772
802
|
}
|
|
773
803
|
|
|
804
|
+
// Lifecycle sort: active first, then idle, terminated last.
|
|
805
|
+
const LIFECYCLE_ORDER = { active: 0, idle: 1, terminated: 2 };
|
|
806
|
+
|
|
774
807
|
function updateSessionBar() {
|
|
775
808
|
sessionBar.innerHTML = '';
|
|
776
|
-
// Sort:
|
|
777
|
-
// recent activity
|
|
809
|
+
// Sort: lifecycle (active → idle → terminated) trumps priority (high → low),
|
|
810
|
+
// then most-recent activity. So a high-priority terminated session sits
|
|
811
|
+
// below a low-priority active one — what's running now matters more.
|
|
778
812
|
const ids = Object.keys(sessions).sort((a, b) => {
|
|
813
|
+
const la = LIFECYCLE_ORDER[sessionStatus(sessions[a])];
|
|
814
|
+
const lb = LIFECYCLE_ORDER[sessionStatus(sessions[b])];
|
|
815
|
+
if (la !== lb) return la - lb;
|
|
779
816
|
const pa = PRIORITY_ORDER[sessions[a].priority] ?? PRIORITY_ORDER[''];
|
|
780
817
|
const pb = PRIORITY_ORDER[sessions[b].priority] ?? PRIORITY_ORDER[''];
|
|
781
818
|
if (pa !== pb) return pa - pb;
|
|
@@ -784,16 +821,19 @@ function updateSessionBar() {
|
|
|
784
821
|
|
|
785
822
|
for (const id of ids) {
|
|
786
823
|
const s = sessions[id];
|
|
824
|
+
const lifecycle = sessionStatus(s);
|
|
787
825
|
const tag = document.createElement('span');
|
|
788
826
|
let cls = 'session-tag';
|
|
789
827
|
if (selectedSession === id) cls += ' active';
|
|
790
828
|
if (s.priority === 'low') cls += ' priority-low';
|
|
829
|
+
cls += ' lifecycle-' + lifecycle;
|
|
791
830
|
tag.className = cls;
|
|
792
831
|
tag.style.background = s.color + '22';
|
|
793
832
|
tag.style.color = s.color;
|
|
794
833
|
tag.title = `${id}${s.purpose ? '\n' + s.purpose : ''}`;
|
|
795
834
|
|
|
796
835
|
const pills = [];
|
|
836
|
+
pills.push(pill('lifecycle-' + lifecycle, lifecycle));
|
|
797
837
|
if (s.persona) pills.push(pill('persona-' + s.persona, s.persona));
|
|
798
838
|
if (s.priority) pills.push(pill('priority-' + s.priority, 'P:' + s.priority));
|
|
799
839
|
if (s.kind) pills.push(pill('kind-' + s.kind, s.kind));
|