paid down the broker's accumulated type debt. zero behavioral changes, purely type-system tightening: - broker.ts: row extraction helper for postgres-js result vs pg shape; findMemberByPubkey defaultGroups null-coalescing. - env.ts: zod default ordered before transform (zod v4 ordering). - index.ts: typed JSON.parse for the tg/token, upload-auth, file-upload, member patch and mesh-settings handlers; export SelfEditablePolicy from member-api; added bodyVersion to WSSendMessage; added the disconnect/kick/ban/unban/list_bans message types to WSClientMessage; String(key) cast for neo4j record symbol-typed keys. - jwt.ts, paths.ts, telegram-token.ts: typed JSON.parse results. - service-manager.ts: typed package.json + MCP JSON-RPC reader. - telegram-bridge.ts: typed WS message handler; missing log import; null-tolerant BridgeRow + skip rows missing memberId/displayName; typed e in catch. - types.ts: bodyVersion on WSSendMessage, manifest on WSSkillData, five new admin message types (kick/disconnect/ban/unban/list_bans). - packages/db/server.ts: drizzle constructor positional args + scoped ts-expect-error for the namespace-bag schema generic mismatch. apps/broker/src/types.ts will eventually want a real audit pass to catch every WS verb and surface the orphans, but this clears the path for 1.30.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
142 lines
4.4 KiB
TypeScript
142 lines
4.4 KiB
TypeScript
/**
|
|
* JSONL session-transcript discovery.
|
|
*
|
|
* Ported verbatim from ~/tools/claude-intercom/broker.ts — including
|
|
* the cross-platform 5-candidate encoding strategy and Roberto's
|
|
* confirmed Windows rule (H:\Claude → H--Claude via [\\/:]→-).
|
|
*
|
|
* Used as the *fallback* status inference path when no fresh hook
|
|
* signal is available for a presence row.
|
|
*/
|
|
|
|
import {
|
|
readdirSync,
|
|
statSync,
|
|
openSync,
|
|
readSync,
|
|
closeSync,
|
|
existsSync,
|
|
} from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
export const PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
const TAIL_BYTES = 8192;
|
|
|
|
/**
|
|
* Generate candidate project-key formats for a given cwd.
|
|
*
|
|
* Claude Code stores session transcripts under
|
|
* `~/.claude/projects/<KEY>/`. The encoding differs per platform:
|
|
*
|
|
* macOS/Linux: /Users/x/foo → "-Users-x-foo" (replace / with -)
|
|
* Windows: H:\Claude → "H--Claude" (replace : and \ with -)
|
|
* Windows: C:\Users\x → "C--Users-x" (same rule)
|
|
*
|
|
* We emit the platform-native candidate first, then fallbacks, so the
|
|
* first directory existence check typically wins.
|
|
*/
|
|
export function cwdToProjectKeyCandidates(cwd: string): string[] {
|
|
const seen = new Set<string>();
|
|
const push = (s: string): void => {
|
|
if (s && !seen.has(s)) seen.add(s);
|
|
};
|
|
|
|
// Most likely: replace /, \, and : with dash. Matches macOS/Linux and
|
|
// Windows (confirmed live: H:\Claude → H--Claude).
|
|
push(cwd.replace(/[\\/:]/g, "-"));
|
|
// Unix legacy (replace / only).
|
|
push(cwd.replaceAll("/", "-"));
|
|
// Replace both separators, keep colons (hypothetical Windows variant).
|
|
push(cwd.replace(/[\\/]/g, "-"));
|
|
// Strip drive letter, then Unix-style.
|
|
const withoutDrive = cwd.replace(/^[A-Za-z]:/, "");
|
|
push(withoutDrive.replace(/[\\/]/g, "-"));
|
|
// Leading-dash fallback for relative-ish paths.
|
|
for (const k of [...seen]) {
|
|
if (!k.startsWith("-")) push("-" + k);
|
|
}
|
|
|
|
return [...seen];
|
|
}
|
|
|
|
/**
|
|
* Find the most recently modified JSONL file for a project, trying
|
|
* each candidate key in order. Returns the first match that exists.
|
|
*/
|
|
export function findActiveJsonl(
|
|
cwd: string,
|
|
): { path: string; mtime: number } | null {
|
|
for (const key of cwdToProjectKeyCandidates(cwd)) {
|
|
const projDir = join(PROJECTS_DIR, key);
|
|
if (!existsSync(projDir)) continue;
|
|
try {
|
|
const files = readdirSync(projDir).filter((f) => f.endsWith(".jsonl"));
|
|
let best: { path: string; mtime: number } | null = null;
|
|
for (const f of files) {
|
|
const full = join(projDir, f);
|
|
try {
|
|
const st = statSync(full);
|
|
const mt = st.mtimeMs;
|
|
if (!best || mt > best.mtime) best = { path: full, mtime: mt };
|
|
} catch {
|
|
/* skip unreadable files */
|
|
}
|
|
}
|
|
if (best) return best;
|
|
} catch {
|
|
/* can't read dir, try next candidate */
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Tail the JSONL file and check whether the last assistant message
|
|
* has a pending tool_use (= the session is actively running a tool).
|
|
*/
|
|
function lastAssistantHasToolUse(filePath: string): boolean {
|
|
try {
|
|
const st = statSync(filePath);
|
|
const size = st.size;
|
|
if (size === 0) return false;
|
|
const readSize = Math.min(TAIL_BYTES, size);
|
|
const buf = Buffer.alloc(readSize);
|
|
const fd = openSync(filePath, "r");
|
|
try {
|
|
readSync(fd, buf, 0, readSize, size - readSize);
|
|
} finally {
|
|
closeSync(fd);
|
|
}
|
|
const tail = buf.toString("utf-8");
|
|
const lines = tail.split("\n");
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
const line = lines[i]?.trim();
|
|
if (!line) continue;
|
|
if (!line.includes('"assistant"')) continue;
|
|
try {
|
|
const d = JSON.parse(line) as { type?: string; message?: { content?: unknown } };
|
|
if (d.type !== "assistant") continue;
|
|
const content = d.message?.content;
|
|
if (!Array.isArray(content)) continue;
|
|
return (content as Array<{ type?: string }>).some((c) => c.type === "tool_use");
|
|
} catch {
|
|
/* malformed line, skip */
|
|
}
|
|
}
|
|
} catch {
|
|
/* file read error */
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Infer peer status from JSONL: "working" if last assistant entry has
|
|
* a pending tool_use, else "idle". Returns "idle" if no transcript.
|
|
*/
|
|
export function inferStatusFromJsonl(cwd: string): "idle" | "working" {
|
|
const jsonl = findActiveJsonl(cwd);
|
|
if (!jsonl) return "idle";
|
|
return lastAssistantHasToolUse(jsonl.path) ? "working" : "idle";
|
|
}
|