Skip to content

Commit f20a022

Browse files
Merge pull request #267 from anipotts/codex/pro/cc-quick-live-roster-2026-07-24
fix(cc): reconcile stale quick-roster rows
2 parents a06eb56 + 8793c2f commit f20a022

2 files changed

Lines changed: 142 additions & 0 deletions

File tree

plugins/cc/bin/cc-quick

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,14 @@ CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
2121
CC_DIR="${CC_STATE_DIR:-$CLAUDE_DIR/channels/cc}"
2222
DB="$CC_DIR/sessions.db"
2323
INBOX="$CC_DIR/inbox"
24+
STALE_SESSION_AFTER_MS="${CC_STALE_SESSION_MS:-300000}"
2425

2526
die() { echo "cc-quick: $*" >&2; exit 1; }
2627
env_err() { echo "cc-quick: $*" >&2; exit 2; }
2728

2829
[ -f "$DB" ] || env_err "sessions.db not at $DB — install/start cc first"
30+
[[ "$STALE_SESSION_AFTER_MS" =~ ^[1-9][0-9]*$ ]] ||
31+
env_err "CC_STALE_SESSION_MS must be a positive integer"
2932
MY_SID="${CLAUDE_CODE_SESSION_ID:-${CLAUDE_SESSION_ID:-}}"
3033
[ -n "$MY_SID" ] || env_err "CLAUDE_CODE_SESSION_ID not set; can't identify self"
3134
MY_SHORT="${MY_SID:0:8}"
@@ -34,6 +37,19 @@ MY_CWD_BASENAME="$(basename "$PWD")"
3437
# ms-precision timestamp (date +%s%N is ns; trim to 13 chars for ms)
3538
now_ms() { date +%s%N | cut -c1-13; }
3639

40+
stale_cutoff_ms() {
41+
printf '%s\n' "$(( $(now_ms) - STALE_SESSION_AFTER_MS ))"
42+
}
43+
44+
# Dirty exits leave ended_at_ms unset. Reconcile old rows before any command
45+
# treats the database as a live roster. Historical rows remain queryable.
46+
reconcile_stale_sessions() {
47+
local cutoff
48+
cutoff="$(stale_cutoff_ms)"
49+
sqlite3 "$DB" \
50+
"UPDATE sessions SET ended_at_ms = last_seen_at_ms WHERE ended_at_ms IS NULL AND last_seen_at_ms <= $cutoff;"
51+
}
52+
3753
# urlsafe random hex of length N. uuidgen if available else /dev/urandom.
3854
rand_hex() {
3955
if command -v uuidgen >/dev/null 2>&1; then
@@ -47,25 +63,32 @@ rand_hex() {
4763
# session_id. Echos the full id, or empty string if not found.
4864
resolve_target() {
4965
local ref="$1"
66+
local cutoff
67+
cutoff="$(stale_cutoff_ms)"
5068
# try short id first (8 hex), then full id, then cwd basename. Prefer
5169
# most-recently-active match on ambiguity.
5270
sqlite3 "$DB" <<SQL | head -1
5371
SELECT id FROM sessions
5472
WHERE ended_at_ms IS NULL
73+
AND last_seen_at_ms > $cutoff
5574
AND (substr(id,1,8) = '$ref' OR id = '$ref' OR cwd LIKE '%/$ref')
5675
ORDER BY last_seen_at_ms DESC
5776
LIMIT 1;
5877
SQL
5978
}
6079

6180
cmd_roster() {
81+
local cutoff
82+
reconcile_stale_sessions
83+
cutoff="$(stale_cutoff_ms)"
6284
sqlite3 -header -column "$DB" <<SQL
6385
SELECT substr(id,1,8) AS sid,
6486
cwd,
6587
COALESCE(branch, '') AS branch,
6688
datetime(last_seen_at_ms/1000, 'unixepoch', 'localtime') AS last_seen
6789
FROM sessions
6890
WHERE ended_at_ms IS NULL
91+
AND last_seen_at_ms > $cutoff
6992
ORDER BY last_seen_at_ms DESC;
7093
SQL
7194
}
@@ -76,6 +99,7 @@ cmd_send() {
7699
local subject="${3:-}"
77100
[ -n "$target_ref" ] || die "send: missing target (short id, full id, or cwd basename)"
78101
[ -n "$message" ] || die "send: missing message"
102+
reconcile_stale_sessions
79103

80104
local target_sid
81105
target_sid=$(resolve_target "$target_ref")
@@ -128,7 +152,10 @@ SQL
128152
cmd_check() {
129153
local since_s="${1:-1800}" # default 30min
130154
local since_ms
155+
local cutoff
156+
reconcile_stale_sessions
131157
since_ms=$(( $(now_ms) - since_s * 1000 ))
158+
cutoff="$(stale_cutoff_ms)"
132159

133160
echo "=== announcements (last ${since_s}s, peers only) ==="
134161
sqlite3 -header -column "$DB" <<SQL
@@ -138,6 +165,7 @@ SELECT substr(a.session_id,1,8) AS sid,
138165
FROM announcements a
139166
JOIN sessions s ON s.id = a.session_id
140167
WHERE s.ended_at_ms IS NULL
168+
AND s.last_seen_at_ms > $cutoff
141169
AND a.session_id != '$MY_SID'
142170
AND a.created_at_ms > $since_ms
143171
ORDER BY a.created_at_ms DESC LIMIT 20;
@@ -151,6 +179,7 @@ SELECT substr(rf.session_id,1,8) AS sid,
151179
FROM recent_files rf
152180
JOIN sessions s ON s.id = rf.session_id
153181
WHERE s.ended_at_ms IS NULL
182+
AND s.last_seen_at_ms > $cutoff
154183
AND rf.session_id != '$MY_SID'
155184
AND rf.touched_at_ms > $since_ms
156185
ORDER BY rf.touched_at_ms DESC LIMIT 20;

plugins/cc/tests/cc-quick.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// tested with: claude code v2.1.133 + bun 1.3
2+
3+
import { Database } from "bun:sqlite";
4+
import { afterEach, describe, expect, it } from "bun:test";
5+
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
6+
import { tmpdir } from "node:os";
7+
import { join } from "node:path";
8+
9+
const fixtures: string[] = [];
10+
const script = join(import.meta.dir, "..", "bin", "cc-quick");
11+
12+
afterEach(() => {
13+
for (const fixture of fixtures.splice(0)) {
14+
rmSync(fixture, { recursive: true, force: true });
15+
}
16+
});
17+
18+
function makeFixture(): {
19+
root: string;
20+
dbPath: string;
21+
now: number;
22+
} {
23+
const root = mkdtempSync(join(tmpdir(), "cc-quick-"));
24+
fixtures.push(root);
25+
const ccDir = join(root, "channels", "cc");
26+
mkdirSync(ccDir, { recursive: true });
27+
const dbPath = join(ccDir, "sessions.db");
28+
const db = new Database(dbPath);
29+
db.exec(`
30+
CREATE TABLE sessions (
31+
id TEXT PRIMARY KEY,
32+
name TEXT,
33+
cwd TEXT,
34+
project_root TEXT,
35+
branch TEXT,
36+
worktree_root TEXT,
37+
role TEXT,
38+
pid INTEGER,
39+
started_at_ms INTEGER NOT NULL,
40+
last_seen_at_ms INTEGER NOT NULL,
41+
last_checked_at_ms INTEGER,
42+
ended_at_ms INTEGER
43+
);
44+
CREATE TABLE announcements (
45+
id TEXT PRIMARY KEY,
46+
session_id TEXT NOT NULL,
47+
summary TEXT NOT NULL,
48+
detail TEXT,
49+
created_at_ms INTEGER NOT NULL
50+
);
51+
CREATE TABLE recent_files (
52+
session_id TEXT NOT NULL,
53+
path TEXT NOT NULL,
54+
touched_at_ms INTEGER NOT NULL
55+
);
56+
`);
57+
const now = Date.now();
58+
const insert = db.prepare(
59+
`INSERT INTO sessions
60+
(id, cwd, branch, started_at_ms, last_seen_at_ms, ended_at_ms)
61+
VALUES (?, ?, ?, ?, ?, NULL)`,
62+
);
63+
insert.run("fresh-session", "/tmp/fresh", "main", now - 1000, now - 1000);
64+
insert.run("stale-session", "/tmp/stale", "main", now - 600000, now - 600000);
65+
db.close();
66+
return { root, dbPath, now };
67+
}
68+
69+
describe("cc-quick live roster", () => {
70+
it("shows fresh rows and marks stale rows ended", () => {
71+
const fixture = makeFixture();
72+
const result = Bun.spawnSync(["bash", script, "roster"], {
73+
env: {
74+
...process.env,
75+
CLAUDE_CODE_SESSION_ID: "fixture-caller",
76+
CLAUDE_CONFIG_DIR: fixture.root,
77+
CC_STALE_SESSION_MS: "300000",
78+
},
79+
});
80+
81+
expect(result.exitCode).toBe(0);
82+
const output = result.stdout.toString();
83+
expect(output).toContain("fresh-se");
84+
expect(output).not.toContain("stale-se");
85+
86+
const db = new Database(fixture.dbPath);
87+
const stale = db
88+
.query("SELECT last_seen_at_ms, ended_at_ms FROM sessions WHERE id = ?")
89+
.get("stale-session") as {
90+
last_seen_at_ms: number;
91+
ended_at_ms: number | null;
92+
};
93+
expect(stale.ended_at_ms).toBe(stale.last_seen_at_ms);
94+
db.close();
95+
});
96+
97+
it("rejects a non-numeric stale-session window", () => {
98+
const fixture = makeFixture();
99+
const result = Bun.spawnSync(["bash", script, "roster"], {
100+
env: {
101+
...process.env,
102+
CLAUDE_CODE_SESSION_ID: "fixture-caller",
103+
CLAUDE_CONFIG_DIR: fixture.root,
104+
CC_STALE_SESSION_MS: "unsafe",
105+
},
106+
});
107+
108+
expect(result.exitCode).toBe(2);
109+
expect(result.stderr.toString()).toContain(
110+
"CC_STALE_SESSION_MS must be a positive integer",
111+
);
112+
});
113+
});

0 commit comments

Comments
 (0)