@scrylog/cli 0.1.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.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +836 -0
  3. package/package.json +75 -0
  4. package/src/api/docs/sessions.keys.ts +6 -0
  5. package/src/api/docs/sessions.ts +81 -0
  6. package/src/api/docs/system.keys.ts +5 -0
  7. package/src/api/docs/system.ts +66 -0
  8. package/src/api/docs/usage.keys.ts +3 -0
  9. package/src/api/docs/usage.ts +24 -0
  10. package/src/api/docs/webhooks.keys.ts +5 -0
  11. package/src/api/docs/webhooks.ts +78 -0
  12. package/src/api/index.ts +18 -0
  13. package/src/api/lib/auth.ts +43 -0
  14. package/src/api/lib/errors.ts +28 -0
  15. package/src/api/lib/route.ts +18 -0
  16. package/src/api/models/claude.ts +4 -0
  17. package/src/api/models/codex.ts +4 -0
  18. package/src/api/models/opencode.ts +11 -0
  19. package/src/api/routes/sessions.ts +47 -0
  20. package/src/api/routes/system.ts +103 -0
  21. package/src/api/routes/usage.ts +15 -0
  22. package/src/api/routes/webhooks/claude.ts +161 -0
  23. package/src/api/routes/webhooks/codex.ts +175 -0
  24. package/src/api/routes/webhooks/opencode.ts +107 -0
  25. package/src/api/routes.ts +93 -0
  26. package/src/api/schemas/common.ts +21 -0
  27. package/src/api/schemas/sessions.ts +72 -0
  28. package/src/api/schemas/usage.ts +50 -0
  29. package/src/api/schemas/webhooks/claude.ts +32 -0
  30. package/src/api/schemas/webhooks/codex.ts +34 -0
  31. package/src/api/schemas/webhooks/opencode.ts +29 -0
  32. package/src/api/services/claude-session.ts +9 -0
  33. package/src/api/services/codex-session.ts +8 -0
  34. package/src/api/services/opencode-session.ts +73 -0
  35. package/src/classifier.ts +206 -0
  36. package/src/cli/commands.ts +498 -0
  37. package/src/cli/prompt.ts +44 -0
  38. package/src/daemon/cli.ts +27 -0
  39. package/src/daemon/collectors/claude/README.md +108 -0
  40. package/src/daemon/collectors/claude/collector.ts +413 -0
  41. package/src/daemon/collectors/claude/discovery.ts +148 -0
  42. package/src/daemon/collectors/claude/hooks.ts +181 -0
  43. package/src/daemon/collectors/claude/index.ts +8 -0
  44. package/src/daemon/collectors/claude/jsonl.ts +85 -0
  45. package/src/daemon/collectors/claude/transcript.ts +246 -0
  46. package/src/daemon/collectors/codex/README.md +147 -0
  47. package/src/daemon/collectors/codex/collector.ts +661 -0
  48. package/src/daemon/collectors/codex/discovery.ts +104 -0
  49. package/src/daemon/collectors/codex/hooks.ts +179 -0
  50. package/src/daemon/collectors/codex/index.ts +7 -0
  51. package/src/daemon/collectors/codex/jsonl.ts +108 -0
  52. package/src/daemon/collectors/codex/transcript.ts +299 -0
  53. package/src/daemon/collectors/git.ts +295 -0
  54. package/src/daemon/collectors/opencode/README.md +106 -0
  55. package/src/daemon/collectors/opencode/collector.ts +543 -0
  56. package/src/daemon/collectors/opencode/discovery.ts +257 -0
  57. package/src/daemon/collectors/opencode/index.ts +5 -0
  58. package/src/daemon/collectors/opencode/session-builder.ts +59 -0
  59. package/src/daemon/collectors/opencode-db.ts +282 -0
  60. package/src/daemon/collectors/process.ts +201 -0
  61. package/src/daemon/collectors/tmux/README.md +128 -0
  62. package/src/daemon/collectors/tmux/collector.ts +472 -0
  63. package/src/daemon/collectors/tmux/enrichment.ts +241 -0
  64. package/src/daemon/collectors/tmux/index.ts +4 -0
  65. package/src/daemon/collectors/tmux/parse.ts +128 -0
  66. package/src/daemon/collectors/usage.ts +193 -0
  67. package/src/daemon/index.ts +207 -0
  68. package/src/daemon/scheduler.ts +86 -0
  69. package/src/daemon/store.ts +535 -0
  70. package/src/detect.ts +143 -0
  71. package/src/hooks/claude.ts +596 -0
  72. package/src/hooks/codex.ts +517 -0
  73. package/src/legacy/classifier.ts +215 -0
  74. package/src/legacy/detect.ts +143 -0
  75. package/src/mcp/client-server.ts +21 -0
  76. package/src/mcp/http-handler.ts +126 -0
  77. package/src/mcp/index.ts +61 -0
  78. package/src/mcp/resources/config-current.ts +19 -0
  79. package/src/mcp/resources/index.ts +11 -0
  80. package/src/mcp/resources/sessions-active.ts +19 -0
  81. package/src/mcp/resources/sessions-all.ts +19 -0
  82. package/src/mcp/schemas/sessions.ts +3 -0
  83. package/src/mcp/server.ts +17 -0
  84. package/src/mcp/services/api-adapter.ts +48 -0
  85. package/src/mcp/services/data-source.ts +22 -0
  86. package/src/mcp/services/store-adapter.ts +40 -0
  87. package/src/mcp/tools/get-active-sessions.ts +16 -0
  88. package/src/mcp/tools/get-session-children.ts +24 -0
  89. package/src/mcp/tools/get-session-messages.ts +24 -0
  90. package/src/mcp/tools/get-session.ts +23 -0
  91. package/src/mcp/tools/index.ts +15 -0
  92. package/src/mcp/tools/list-sessions.ts +16 -0
  93. package/src/neo-blessed.d.ts +4 -0
  94. package/src/oc/index.ts +302 -0
  95. package/src/service/manager.ts +242 -0
  96. package/src/shared/config.ts +478 -0
  97. package/src/shared/ipc.ts +80 -0
  98. package/src/shared/logger.ts +248 -0
  99. package/src/shared/paths.ts +73 -0
  100. package/src/shared/platform.ts +110 -0
  101. package/src/shared/session-view.ts +241 -0
  102. package/src/shared/types.ts +299 -0
  103. package/src/shared/utils.ts +339 -0
  104. package/src/tui/app.ts +920 -0
  105. package/src/tui/client.ts +149 -0
  106. package/src/tui/index.ts +181 -0
  107. package/src/types.ts +113 -0
  108. package/src/usage/README.md +323 -0
  109. package/src/usage/api.ts +99 -0
  110. package/src/usage/client.ts +3 -0
  111. package/src/usage/clients/anthropic-oauth-usage.ts +178 -0
  112. package/src/usage/clients/anthropic-web-usage.ts +245 -0
  113. package/src/usage/clients/claude-cli-probe.ts +187 -0
  114. package/src/usage/clients/codex-oauth-usage.ts +113 -0
  115. package/src/usage/clients/opencode-go-usage.ts +441 -0
  116. package/src/usage/index.ts +19 -0
  117. package/src/usage/mappers.ts +273 -0
  118. package/src/usage/planner.ts +54 -0
  119. package/src/usage/providers/claude.ts +113 -0
  120. package/src/usage/providers/codex.ts +34 -0
  121. package/src/usage/providers/opencode-go.ts +37 -0
  122. package/src/usage/resolver.ts +4 -0
  123. package/src/usage/resolvers/browser-cookies.ts +198 -0
  124. package/src/usage/resolvers/claude-auth.ts +99 -0
  125. package/src/usage/resolvers/claude-cli.ts +24 -0
  126. package/src/usage/resolvers/claude-web.ts +16 -0
  127. package/src/usage/resolvers/codex-auth.ts +117 -0
  128. package/src/usage/resolvers/opencode-go-web.ts +14 -0
  129. package/src/usage/types.ts +190 -0
@@ -0,0 +1,248 @@
1
+ import { appendFileSync, existsSync, mkdirSync, renameSync, statSync, unlinkSync } from "node:fs";
2
+ import { dirname, extname, join, basename } from "node:path";
3
+ import { homedir } from "node:os";
4
+
5
+ // ── Log levels ─────────────────────────────────────────────────────────────────
6
+
7
+ export type LogLevel = "debug" | "info" | "warn" | "error";
8
+ export type LogFormat = "auto" | "pretty" | "json";
9
+ export type LoggerWriter = (line: string) => void;
10
+
11
+ export interface LoggerOptions {
12
+ level: LogLevel;
13
+ format?: LogFormat;
14
+ file?: {
15
+ enabled: boolean;
16
+ path: string;
17
+ maxBytes: number;
18
+ maxFiles: number;
19
+ };
20
+ writer?: LoggerWriter;
21
+ }
22
+
23
+ const LEVEL_RANK: Record<LogLevel, number> = {
24
+ debug: 0,
25
+ info: 1,
26
+ warn: 2,
27
+ error: 3,
28
+ };
29
+
30
+ const LEVEL_LABEL: Record<LogLevel, string> = {
31
+ debug: "DBG",
32
+ info: "INF",
33
+ warn: "WRN",
34
+ error: "ERR",
35
+ };
36
+
37
+ const LEVEL_COLOR: Record<LogLevel, string> = {
38
+ debug: "\x1b[2m", // dim
39
+ info: "\x1b[36m", // cyan
40
+ warn: "\x1b[33m", // yellow
41
+ error: "\x1b[31m", // red
42
+ };
43
+ const ANSI_RESET = "\x1b[0m";
44
+
45
+ // ── Logger ─────────────────────────────────────────────────────────────────────
46
+
47
+ export class Logger {
48
+ private readonly minLevel: LogLevel;
49
+ private readonly format: LogFormat;
50
+ private readonly file?: NonNullable<LoggerOptions["file"]>;
51
+ private readonly writer: LoggerWriter;
52
+
53
+ constructor(optionsOrLevel: LoggerOptions | LogLevel) {
54
+ const options = typeof optionsOrLevel === "string" ? { level: optionsOrLevel } : optionsOrLevel;
55
+ this.minLevel = options.level;
56
+ this.format = options.format ?? "auto";
57
+ this.file = options.file?.enabled ? options.file : undefined;
58
+ this.writer = options.writer ?? ((line) => process.stderr.write(line + "\n"));
59
+ }
60
+
61
+ debug(source: string, message: string, data?: Record<string, unknown>): void {
62
+ this.write("debug", source, message, data);
63
+ }
64
+
65
+ info(source: string, message: string, data?: Record<string, unknown>): void {
66
+ this.write("info", source, message, data);
67
+ }
68
+
69
+ warn(source: string, message: string, data?: Record<string, unknown>): void {
70
+ this.write("warn", source, message, data);
71
+ }
72
+
73
+ error(source: string, message: string, data?: Record<string, unknown>): void {
74
+ this.write("error", source, message, data);
75
+ }
76
+
77
+ private write(level: LogLevel, source: string, message: string, data?: Record<string, unknown>): void {
78
+ if (LEVEL_RANK[level] < LEVEL_RANK[this.minLevel]) return;
79
+
80
+ const now = new Date();
81
+ const isoTs = now.toISOString();
82
+ const hh = String(now.getHours()).padStart(2, "0");
83
+ const mm = String(now.getMinutes()).padStart(2, "0");
84
+ const ss = String(now.getSeconds()).padStart(2, "0");
85
+ const ms = String(now.getMilliseconds()).padStart(3, "0");
86
+ const ts = `${hh}:${mm}:${ss}.${ms}`;
87
+ const label = LEVEL_LABEL[level];
88
+
89
+ const { event, fields } = splitEventData(sanitizeData(data));
90
+ const dataPart = formatPrettyData(fields);
91
+
92
+ const isTTY = process.stderr.isTTY === true;
93
+ const resolvedFormat = this.format === "auto" ? (isTTY ? "pretty" : "json") : this.format;
94
+ let line: string;
95
+
96
+ if (resolvedFormat === "json") {
97
+ line = JSON.stringify({
98
+ ts: isoTs,
99
+ level,
100
+ source,
101
+ ...(event ? { event } : {}),
102
+ msg: message,
103
+ ...(Object.keys(fields).length > 0 ? { data: fields } : {}),
104
+ });
105
+ } else if (level === "debug") {
106
+ // Whole line dimmed — visually recessive, won't distract from important logs
107
+ line = `${LEVEL_COLOR.debug}[${ts}] [${label}] [${source}]${event ? ` ${event}` : ""} ${message}${dataPart}${ANSI_RESET}`;
108
+ } else if (level === "info") {
109
+ // Only the label colored — clean, easy to scan
110
+ line = `[${ts}] ${LEVEL_COLOR.info}[${label}]${ANSI_RESET} [${source}]${event ? ` ${event}` : ""} ${message}${dataPart}`;
111
+ } else {
112
+ // warn / error — whole line colored so nothing is missed
113
+ line = `${LEVEL_COLOR[level]}[${ts}] [${label}] [${source}]${event ? ` ${event}` : ""} ${message}${dataPart}${ANSI_RESET}`;
114
+ }
115
+
116
+ this.writer(line);
117
+
118
+ if (this.file) {
119
+ const fileFields = fields;
120
+ const jsonLine = JSON.stringify({
121
+ ts: isoTs,
122
+ level,
123
+ source,
124
+ ...(event ? { event } : {}),
125
+ msg: message,
126
+ ...(Object.keys(fileFields).length > 0 ? { data: fileFields } : {}),
127
+ });
128
+ writeJsonLineToFile(this.file, jsonLine);
129
+ }
130
+ }
131
+ }
132
+
133
+ export function createLogger(optionsOrLevel: LoggerOptions | LogLevel): Logger {
134
+ return new Logger(optionsOrLevel);
135
+ }
136
+
137
+ /** Parse a log level string, falling back to the default if invalid. */
138
+ export function parseLogLevel(value: string | undefined, fallback: LogLevel = "info"): LogLevel {
139
+ if (value === "debug" || value === "info" || value === "warn" || value === "error") {
140
+ return value;
141
+ }
142
+ return fallback;
143
+ }
144
+
145
+ /** Parse a log format string, falling back to the default if invalid. */
146
+ export function parseLogFormat(value: string | undefined, fallback: LogFormat = "auto"): LogFormat {
147
+ if (value === "auto" || value === "pretty" || value === "json") {
148
+ return value;
149
+ }
150
+ return fallback;
151
+ }
152
+
153
+ /** Singleton noop logger for use where no logger is injected. */
154
+ export const NOOP_LOGGER = new Logger("error" as LogLevel);
155
+ // Override write to do nothing — used in tests / non-daemon contexts
156
+ (NOOP_LOGGER as unknown as { write: () => void }).write = () => {};
157
+
158
+ function splitEventData(data: Record<string, unknown> | undefined): { event?: string; fields: Record<string, unknown> } {
159
+ if (!data) return { fields: {} };
160
+ const { event, ...rest } = data;
161
+ return {
162
+ ...(typeof event === "string" && event.length > 0 ? { event } : {}),
163
+ fields: rest,
164
+ };
165
+ }
166
+
167
+ function formatPrettyData(data: Record<string, unknown>): string {
168
+ if (Object.keys(data).length === 0) return "";
169
+ const pairs = Object.entries(data)
170
+ .map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
171
+ .join(" ");
172
+ return ` ${pairs}`;
173
+ }
174
+
175
+ function sanitizeData(data: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
176
+ if (!data) return undefined;
177
+ const sanitized: Record<string, unknown> = {};
178
+ for (const [key, value] of Object.entries(data)) {
179
+ sanitized[key] = sanitizeValue(key, value);
180
+ }
181
+ return sanitized;
182
+ }
183
+
184
+ function sanitizeValue(key: string, value: unknown): unknown {
185
+ if (value === null || value === undefined) return value;
186
+ if (typeof value === "string") return sanitizeString(key, value);
187
+ if (Array.isArray(value)) return value.map((item) => sanitizeValue(key, item));
188
+ if (typeof value === "object") {
189
+ const out: Record<string, unknown> = {};
190
+ for (const [childKey, childValue] of Object.entries(value)) {
191
+ out[childKey] = sanitizeValue(childKey, childValue);
192
+ }
193
+ return out;
194
+ }
195
+ return value;
196
+ }
197
+
198
+ function sanitizeString(key: string, value: string): string {
199
+ if (/authorization|token|secret|password|bearer/i.test(key)) return "[redacted]";
200
+ let next = value.replace(new RegExp(escapeRegExp(homedir()), "g"), "~");
201
+ next = next.replace(/(Authorization:\s*Bearer\s+)[^\s]+/gi, "$1[redacted]");
202
+ next = next.replace(/(Bearer\s+)[A-Za-z0-9._~+\-/]+=*/g, "$1[redacted]");
203
+ next = next.replace(/([?&](?:token|access_token|api_key|key|secret|password)=)[^&\s]+/gi, "$1[redacted]");
204
+ next = next.replace(/(https?:\/\/[^\s?]+)\?[^\s]+/gi, "$1?[redacted]");
205
+ return next;
206
+ }
207
+
208
+ function writeJsonLineToFile(file: NonNullable<LoggerOptions["file"]>, line: string): void {
209
+ try {
210
+ mkdirSync(dirname(file.path), { recursive: true });
211
+ rotateIfNeeded(file);
212
+ appendFileSync(file.path, `${line}\n`, "utf8");
213
+ } catch {
214
+ // Logging must never crash the daemon.
215
+ }
216
+ }
217
+
218
+ function rotateIfNeeded(file: NonNullable<LoggerOptions["file"]>): void {
219
+ if (file.maxBytes <= 0 || !existsSync(file.path)) return;
220
+ const size = statSync(file.path).size;
221
+ if (size < file.maxBytes) return;
222
+
223
+ const maxFiles = Math.max(1, file.maxFiles);
224
+ const rotatedCount = maxFiles - 1;
225
+ if (rotatedCount <= 0) {
226
+ unlinkSync(file.path);
227
+ return;
228
+ }
229
+
230
+ const oldest = rotatedPath(file.path, rotatedCount);
231
+ if (existsSync(oldest)) unlinkSync(oldest);
232
+
233
+ for (let index = rotatedCount - 1; index >= 1; index--) {
234
+ const from = rotatedPath(file.path, index);
235
+ if (existsSync(from)) renameSync(from, rotatedPath(file.path, index + 1));
236
+ }
237
+ renameSync(file.path, rotatedPath(file.path, 1));
238
+ }
239
+
240
+ function rotatedPath(path: string, index: number): string {
241
+ const ext = extname(path);
242
+ const base = basename(path, ext);
243
+ return join(dirname(path), `${base}.${index}${ext}`);
244
+ }
245
+
246
+ function escapeRegExp(value: string): string {
247
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
248
+ }
@@ -0,0 +1,73 @@
1
+ import { mkdirSync } from "node:fs";
2
+ import { homedir, userInfo } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ export function getConfigDir(): string {
6
+ return join(homedir(), ".config", "scrylog");
7
+ }
8
+
9
+ export function getDefaultConfigPath(): string {
10
+ return join(getConfigDir(), "config.json");
11
+ }
12
+
13
+ export function getLegacyConfigPath(): string {
14
+ return join(homedir(), ".scrylog.json");
15
+ }
16
+
17
+ export function getStateDir(): string {
18
+ return join(homedir(), ".local", "state", "scrylog");
19
+ }
20
+
21
+ export function getDaemonLogPath(): string {
22
+ return join(homedir(), ".scrylog", "logs", "scrylogd.log");
23
+ }
24
+
25
+ export function getServiceLabel(): string {
26
+ return "com.scrylog.daemon";
27
+ }
28
+
29
+ export function getLaunchAgentsDir(): string {
30
+ return join(homedir(), "Library", "LaunchAgents");
31
+ }
32
+
33
+ export function getLaunchAgentPath(): string {
34
+ return join(getLaunchAgentsDir(), `${getServiceLabel()}.plist`);
35
+ }
36
+
37
+ export function getSystemdUserDir(): string {
38
+ return join(homedir(), ".config", "systemd", "user");
39
+ }
40
+
41
+ export function getSystemdUnitName(): string {
42
+ return "scrylogd.service";
43
+ }
44
+
45
+ export function getSystemdUnitPath(): string {
46
+ return join(getSystemdUserDir(), getSystemdUnitName());
47
+ }
48
+
49
+ export function ensureParentDir(path: string): void {
50
+ const dir = path.endsWith("/") ? path.slice(0, -1) : dirname(path);
51
+ if (!dir || dir === ".") return;
52
+ mkdirSync(dir, { recursive: true });
53
+ }
54
+
55
+ export function ensureConfigDir(): string {
56
+ const dir = getConfigDir();
57
+ mkdirSync(dir, { recursive: true });
58
+ return dir;
59
+ }
60
+
61
+ export function ensureStateDir(): string {
62
+ const dir = getStateDir();
63
+ mkdirSync(dir, { recursive: true });
64
+ return dir;
65
+ }
66
+
67
+ export function getUserUid(): number | string {
68
+ try {
69
+ return userInfo().uid;
70
+ } catch {
71
+ return process.env.USER ?? process.env.USERNAME ?? "default";
72
+ }
73
+ }
@@ -0,0 +1,110 @@
1
+ import { homedir, tmpdir, userInfo } from "node:os";
2
+ import { join } from "node:path";
3
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, rmSync } from "node:fs";
4
+
5
+ /** True on Windows where Unix sockets are not available */
6
+ export const IS_WINDOWS = process.platform === "win32";
7
+
8
+ /** True on macOS */
9
+ export const IS_MAC = process.platform === "darwin";
10
+
11
+ /** True on Linux (including WSL) */
12
+ export const IS_LINUX = process.platform === "linux";
13
+
14
+ /** True when running inside Windows Subsystem for Linux */
15
+ export const IS_WSL = ((): boolean => {
16
+ if (!IS_LINUX) return false;
17
+ try {
18
+ const version = readFileSync("/proc/version", "utf8");
19
+ return /microsoft|wsl/i.test(version);
20
+ } catch {
21
+ return false;
22
+ }
23
+ })();
24
+
25
+ /**
26
+ * Resolve the path to an executable using the platform-appropriate command.
27
+ * Prefers Bun.which when available, falling back to `which` on Unix and `where` on Windows.
28
+ */
29
+ export function whichCommand(name: string): string | null {
30
+ const viaBun = (Bun as typeof Bun & { which?: (name: string) => string | null }).which?.(name);
31
+ if (viaBun) return viaBun;
32
+
33
+ if (IS_WINDOWS) {
34
+ return spawnAndRead("where", [name]);
35
+ }
36
+ return spawnAndRead("which", [name]);
37
+ }
38
+
39
+ function spawnAndRead(command: string, args: string[]): string | null {
40
+ try {
41
+ const proc = Bun.spawnSync([command, ...args], { stdout: "pipe", stderr: "ignore" });
42
+ const text = new TextDecoder().decode(proc.stdout).trim();
43
+ return text ? text.split(/\r?\n/)[0] ?? text : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Return the current user's id, used to scope shared /tmp paths so multiple
51
+ * users on the same host don't conflict or collide on the same path.
52
+ */
53
+ export function getUserId(): number | string {
54
+ try {
55
+ return userInfo().uid;
56
+ } catch {
57
+ // Fallback: use a hash of the username
58
+ return process.env.USER ?? process.env.USERNAME ?? "default";
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Return a stable hooks directory path for the given hook name.
64
+ * On Unix we intentionally avoid os.tmpdir() because macOS per-session temp paths
65
+ * would break rendezvous between independently-launched daemon and hook binaries.
66
+ * The path is scoped by uid so multiple users on the same host don't conflict.
67
+ * On Windows we use os.tmpdir() because %TEMP% is stable per user session and
68
+ * already per-user.
69
+ *
70
+ * This function is pure (no I/O). Callers that need to actually create the
71
+ * directory should use `ensureHooksDirSafe`.
72
+ */
73
+ export function getHooksDir(name: string): string {
74
+ if (IS_WINDOWS) {
75
+ return join(tmpdir(), `scrylog-${name}`);
76
+ }
77
+ return join("/tmp", `scrylog-${getUserId()}-${name}`);
78
+ }
79
+
80
+ function isOwnedDirectory(dir: string): boolean {
81
+ const stat = lstatSync(dir);
82
+ return stat.isDirectory() && stat.uid === process.getuid?.();
83
+ }
84
+
85
+ /**
86
+ * Ensure `dir` exists and, on Unix, is a real directory (not a symlink)
87
+ * owned by the current user, tightening its mode to 0700. A pre-existing
88
+ * path that isn't a directory we own — e.g. a symlink or a directory
89
+ * planted by another local user on a shared host — is untrusted: it is
90
+ * removed and recreated rather than written into. A directory we already
91
+ * own is tightened in place (not recreated) so existing session state isn't
92
+ * discarded. Ownership/mode checks are skipped on Windows, where
93
+ * `getHooksDir` already resolves to a per-user temp directory.
94
+ */
95
+ export function ensureHooksDirSafe(dir: string): void {
96
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
97
+
98
+ if (IS_WINDOWS) return;
99
+
100
+ if (!isOwnedDirectory(dir)) {
101
+ rmSync(dir, { recursive: true, force: true });
102
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
103
+
104
+ if (!isOwnedDirectory(dir)) {
105
+ throw new Error(`Refusing to use untrusted hooks directory: ${dir}`);
106
+ }
107
+ }
108
+
109
+ chmodSync(dir, 0o700);
110
+ }
@@ -0,0 +1,241 @@
1
+ import type { ScrylogConfig } from "./config";
2
+ import type { ProjectInfo, SessionModel, SessionState, SessionTool } from "./types";
3
+
4
+ export interface SessionListProjectInfo {
5
+ displayName: string;
6
+ compactLabel: string;
7
+ branch?: string;
8
+ isGitRepository?: boolean;
9
+ sourcePath?: string;
10
+ gitRootPath?: string;
11
+ repositoryIdentityPath?: string;
12
+ }
13
+
14
+ export interface SessionListTmuxInfo {
15
+ session: string;
16
+ window: string;
17
+ pane: string;
18
+ }
19
+
20
+ export interface SessionListItem {
21
+ id: string;
22
+ source: SessionModel["source"];
23
+ tool: SessionTool;
24
+ state: SessionState;
25
+ summary: string;
26
+ lastActivity: number;
27
+ stableForMs?: number;
28
+ hasAttention?: boolean;
29
+ detectionSource?: SessionModel["detectionSource"];
30
+ parentSessionId?: string;
31
+ project?: SessionListProjectInfo;
32
+ tmux?: SessionListTmuxInfo;
33
+ currentPath?: string;
34
+ activeTool?: string;
35
+ childCount?: number;
36
+ activeChildCount?: number;
37
+ }
38
+
39
+ export interface SessionTreeItem extends SessionListItem {
40
+ children: SessionListItem[];
41
+ }
42
+
43
+ export interface SessionViewCounts {
44
+ total: number;
45
+ visible: number;
46
+ rootsVisible: number;
47
+ childrenVisible: number;
48
+ byState: Record<SessionState, number>;
49
+ byTool: Record<SessionTool, number>;
50
+ }
51
+
52
+ export interface SessionView {
53
+ counts: SessionViewCounts;
54
+ sessions: SessionTreeItem[];
55
+ }
56
+
57
+ function makeStateCounts(): Record<SessionState, number> {
58
+ return {
59
+ working: 0,
60
+ idle: 0,
61
+ "waiting-input": 0,
62
+ error: 0,
63
+ shell: 0,
64
+ unknown: 0,
65
+ };
66
+ }
67
+
68
+ function makeToolCounts(): Record<SessionTool, number> {
69
+ return {
70
+ opencode: 0,
71
+ claude: 0,
72
+ codex: 0,
73
+ copilotcli: 0,
74
+ shell: 0,
75
+ unknown: 0,
76
+ };
77
+ }
78
+
79
+ const CODEX_ROOT_EPHEMERAL_GRACE_MS = 30_000;
80
+
81
+ function isEphemeralCodexRootSession(session: SessionModel, now: number): boolean {
82
+ return session.tool === "codex"
83
+ && session.source === "standalone"
84
+ && session.detectionSource === "codex-hook"
85
+ && session.state === "idle"
86
+ && session.currentPath === "/"
87
+ && !session.codex?.transcriptPath
88
+ && !session.parentSessionId
89
+ && (session.childSessionIds?.length ?? 0) === 0
90
+ && now - session.lastActivity > CODEX_ROOT_EPHEMERAL_GRACE_MS;
91
+ }
92
+
93
+ function isCanonicallyVisible(session: SessionModel, config: ScrylogConfig, now: number): boolean {
94
+ if (session.parentSessionId) {
95
+ return session.state === "working" || session.state === "waiting-input";
96
+ }
97
+
98
+ if (isEphemeralCodexRootSession(session, now)) {
99
+ return false;
100
+ }
101
+
102
+ const cutoff = config.tui.standaloneMaxAgeHours > 0
103
+ ? now - config.tui.standaloneMaxAgeHours * 3_600_000
104
+ : 0;
105
+
106
+ if (cutoff > 0 && session.source === "standalone" && session.lastActivity < cutoff) {
107
+ return false;
108
+ }
109
+
110
+ return true;
111
+ }
112
+
113
+ function toProject(project?: ProjectInfo): SessionListProjectInfo | undefined {
114
+ if (!project) return undefined;
115
+ return {
116
+ displayName: project.displayName,
117
+ compactLabel: project.compactLabel,
118
+ branch: project.branch,
119
+ isGitRepository: project.isGitRepository,
120
+ sourcePath: project.sourcePath,
121
+ gitRootPath: project.gitRootPath,
122
+ repositoryIdentityPath: project.repositoryIdentityPath,
123
+ };
124
+ }
125
+
126
+ const GRACE_PERIOD_MS = 1500;
127
+
128
+ function effectiveState(session: SessionModel, activeChildCount: number, now: number): SessionState {
129
+ if (session.state === "idle" && !session.parentSessionId) {
130
+ if (activeChildCount > 0) {
131
+ return "working";
132
+ }
133
+ if (session.lastChildActiveAt && (now - session.lastChildActiveAt) < GRACE_PERIOD_MS) {
134
+ return "working";
135
+ }
136
+ }
137
+ return session.state;
138
+ }
139
+
140
+ function stableDuration(session: SessionModel, effective: SessionState, now: number): number {
141
+ if (effective === "working" && session.lastWorkingChangedAt) {
142
+ return Math.max(0, now - session.lastWorkingChangedAt);
143
+ }
144
+ if (session.stateChangedAt) {
145
+ return Math.max(0, now - session.stateChangedAt);
146
+ }
147
+ return session.stableForMs ?? Math.max(0, now - session.lastActivity);
148
+ }
149
+
150
+ function toListItem(session: SessionModel, childCount: number, activeChildCount: number, now: number): SessionListItem {
151
+ const effective = effectiveState(session, activeChildCount, now);
152
+ return {
153
+ id: session.id,
154
+ source: session.source,
155
+ tool: session.tool,
156
+ state: effective,
157
+ summary: session.summary,
158
+ lastActivity: session.lastActivity,
159
+ stableForMs: stableDuration(session, effective, now),
160
+ hasAttention: session.hasAttention,
161
+ detectionSource: session.detectionSource,
162
+ parentSessionId: session.parentSessionId,
163
+ project: toProject(session.project),
164
+ tmux: session.tmux,
165
+ currentPath: session.currentPath,
166
+ activeTool: session.activeTool,
167
+ childCount,
168
+ activeChildCount,
169
+ };
170
+ }
171
+
172
+ export function flattenSessionTree(sessions: SessionTreeItem[]): SessionListItem[] {
173
+ const flat: SessionListItem[] = [];
174
+ for (const session of sessions) {
175
+ const { children: _children, ...root } = session;
176
+ flat.push(root);
177
+ flat.push(...session.children);
178
+ }
179
+ return flat;
180
+ }
181
+
182
+ export function buildSessionView(sessions: SessionModel[], config: ScrylogConfig, now = Date.now()): SessionView {
183
+ const byId = new Map(sessions.map((session) => [session.id, session]));
184
+ const allChildren = new Map<string, SessionModel[]>();
185
+ const visibleChildren = new Map<string, SessionModel[]>();
186
+ const visibleRoots = new Map<string, SessionModel>();
187
+
188
+ for (const session of sessions) {
189
+ if (session.parentSessionId) {
190
+ const arr = allChildren.get(session.parentSessionId) ?? [];
191
+ arr.push(session);
192
+ allChildren.set(session.parentSessionId, arr);
193
+
194
+ if (isCanonicallyVisible(session, config, now)) {
195
+ const visibleArr = visibleChildren.get(session.parentSessionId) ?? [];
196
+ visibleArr.push(session);
197
+ visibleChildren.set(session.parentSessionId, visibleArr);
198
+
199
+ const parent = byId.get(session.parentSessionId);
200
+ if (parent) {
201
+ visibleRoots.set(parent.id, parent);
202
+ }
203
+ }
204
+ continue;
205
+ }
206
+
207
+ if (isCanonicallyVisible(session, config, now)) {
208
+ visibleRoots.set(session.id, session);
209
+ }
210
+ }
211
+
212
+ const sortedRoots = [...visibleRoots.values()].sort((a, b) => b.lastActivity - a.lastActivity);
213
+ const tree: SessionTreeItem[] = sortedRoots.map((root) => {
214
+ const activeChildren = (visibleChildren.get(root.id) ?? []).sort((a, b) => b.lastActivity - a.lastActivity);
215
+ const allDirectChildren = allChildren.get(root.id) ?? [];
216
+ return {
217
+ ...toListItem(root, allDirectChildren.length, activeChildren.length, now),
218
+ children: activeChildren.map((child) => toListItem(child, 0, 0, now)),
219
+ };
220
+ });
221
+
222
+ const flatVisible = flattenSessionTree(tree);
223
+ const byState = makeStateCounts();
224
+ const byTool = makeToolCounts();
225
+ for (const session of flatVisible) {
226
+ byState[session.state] += 1;
227
+ byTool[session.tool] += 1;
228
+ }
229
+
230
+ return {
231
+ counts: {
232
+ total: sessions.length,
233
+ visible: flatVisible.length,
234
+ rootsVisible: tree.length,
235
+ childrenVisible: flatVisible.filter((session) => Boolean(session.parentSessionId)).length,
236
+ byState,
237
+ byTool,
238
+ },
239
+ sessions: tree,
240
+ };
241
+ }