@scrylog/cli 0.2.0 → 0.3.1

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 (171) hide show
  1. package/README.md +46 -7
  2. package/dist/assets/highlights-eq9cgrbb.scm +604 -0
  3. package/dist/assets/highlights-ghv9g403.scm +205 -0
  4. package/dist/assets/highlights-hk7bwhj4.scm +284 -0
  5. package/dist/assets/highlights-r812a2qc.scm +150 -0
  6. package/dist/assets/highlights-x6tmsnaa.scm +115 -0
  7. package/dist/assets/injections-73j83es3.scm +27 -0
  8. package/dist/assets/parser.worker-xyhf83rf.js +4563 -0
  9. package/dist/assets/tree-sitter-3jzf13jk.wasm +0 -0
  10. package/dist/assets/tree-sitter-javascript-nd0q4pe9.wasm +0 -0
  11. package/dist/assets/tree-sitter-markdown-411r6y9b.wasm +0 -0
  12. package/dist/assets/tree-sitter-markdown_inline-j5349f42.wasm +0 -0
  13. package/dist/assets/tree-sitter-typescript-zxjzwt75.wasm +0 -0
  14. package/dist/assets/tree-sitter-zig-e78zbjpm.wasm +0 -0
  15. package/dist/cli/assets/providers/SOURCES.md +21 -0
  16. package/dist/cli/assets/providers/claude-terminal.png +0 -0
  17. package/dist/cli/assets/providers/claude-terminal.svg +62 -0
  18. package/dist/cli/assets/providers/claude.png +0 -0
  19. package/dist/cli/assets/providers/copilot.png +0 -0
  20. package/dist/cli/assets/providers/copilot.svg +44 -0
  21. package/dist/cli/assets/providers/openai-terminal.png +0 -0
  22. package/dist/cli/assets/providers/openai-terminal.svg +62 -0
  23. package/dist/cli/assets/providers/openai.png +0 -0
  24. package/dist/cli/assets/providers/opencode-terminal.png +0 -0
  25. package/dist/cli/assets/providers/opencode-terminal.svg +62 -0
  26. package/dist/cli/assets/providers/opencode.png +0 -0
  27. package/dist/cli/assets/providers/shell.png +0 -0
  28. package/dist/cli/assets/providers/shell.svg +23 -0
  29. package/dist/cli/assets/providers/unknown.png +0 -0
  30. package/dist/cli/assets/providers/unknown.svg +23 -0
  31. package/dist/cli/index.js +499 -0
  32. package/dist/cli/index.js.map +61 -0
  33. package/dist/daemon/index.js +670 -0
  34. package/dist/daemon/index.js.map +1475 -0
  35. package/dist/hooks/claude.js +6 -0
  36. package/dist/hooks/claude.js.map +11 -0
  37. package/dist/hooks/codex.js +10 -0
  38. package/dist/hooks/codex.js.map +11 -0
  39. package/dist/mcp/index.js +100 -0
  40. package/dist/mcp/index.js.map +231 -0
  41. package/dist/oc/index.js +21 -0
  42. package/dist/oc/index.js.map +10 -0
  43. package/package.json +26 -20
  44. package/src/postinstall.cjs +11 -0
  45. package/src/api/docs/sessions.keys.ts +0 -6
  46. package/src/api/docs/sessions.ts +0 -81
  47. package/src/api/docs/system.keys.ts +0 -5
  48. package/src/api/docs/system.ts +0 -66
  49. package/src/api/docs/usage.keys.ts +0 -3
  50. package/src/api/docs/usage.ts +0 -24
  51. package/src/api/docs/webhooks.keys.ts +0 -5
  52. package/src/api/docs/webhooks.ts +0 -78
  53. package/src/api/index.ts +0 -18
  54. package/src/api/lib/auth.ts +0 -43
  55. package/src/api/lib/errors.ts +0 -28
  56. package/src/api/lib/route.ts +0 -18
  57. package/src/api/models/claude.ts +0 -4
  58. package/src/api/models/codex.ts +0 -4
  59. package/src/api/models/opencode.ts +0 -11
  60. package/src/api/routes/sessions.ts +0 -47
  61. package/src/api/routes/system.ts +0 -103
  62. package/src/api/routes/usage.ts +0 -15
  63. package/src/api/routes/webhooks/claude.ts +0 -141
  64. package/src/api/routes/webhooks/codex.ts +0 -175
  65. package/src/api/routes/webhooks/opencode.ts +0 -111
  66. package/src/api/routes.ts +0 -93
  67. package/src/api/schemas/common.ts +0 -21
  68. package/src/api/schemas/sessions.ts +0 -72
  69. package/src/api/schemas/usage.ts +0 -50
  70. package/src/api/schemas/webhooks/claude.ts +0 -32
  71. package/src/api/schemas/webhooks/codex.ts +0 -34
  72. package/src/api/schemas/webhooks/opencode.ts +0 -29
  73. package/src/api/services/claude-session.ts +0 -9
  74. package/src/api/services/codex-session.ts +0 -8
  75. package/src/api/services/opencode-session.ts +0 -73
  76. package/src/classifier.ts +0 -206
  77. package/src/cli/commands.ts +0 -632
  78. package/src/cli/prompt.ts +0 -44
  79. package/src/daemon/cli.ts +0 -27
  80. package/src/daemon/collectors/claude/README.md +0 -108
  81. package/src/daemon/collectors/claude/collector.ts +0 -423
  82. package/src/daemon/collectors/claude/discovery.ts +0 -148
  83. package/src/daemon/collectors/claude/hooks.ts +0 -181
  84. package/src/daemon/collectors/claude/index.ts +0 -8
  85. package/src/daemon/collectors/claude/jsonl.ts +0 -85
  86. package/src/daemon/collectors/claude/transcript.ts +0 -246
  87. package/src/daemon/collectors/codex/README.md +0 -147
  88. package/src/daemon/collectors/codex/collector.ts +0 -661
  89. package/src/daemon/collectors/codex/discovery.ts +0 -104
  90. package/src/daemon/collectors/codex/hooks.ts +0 -179
  91. package/src/daemon/collectors/codex/index.ts +0 -7
  92. package/src/daemon/collectors/codex/jsonl.ts +0 -108
  93. package/src/daemon/collectors/codex/transcript.ts +0 -299
  94. package/src/daemon/collectors/git.ts +0 -295
  95. package/src/daemon/collectors/opencode/README.md +0 -106
  96. package/src/daemon/collectors/opencode/collector.ts +0 -545
  97. package/src/daemon/collectors/opencode/discovery.ts +0 -257
  98. package/src/daemon/collectors/opencode/index.ts +0 -5
  99. package/src/daemon/collectors/opencode/session-builder.ts +0 -59
  100. package/src/daemon/collectors/opencode-db.ts +0 -282
  101. package/src/daemon/collectors/process.ts +0 -201
  102. package/src/daemon/collectors/tmux/README.md +0 -128
  103. package/src/daemon/collectors/tmux/collector.ts +0 -472
  104. package/src/daemon/collectors/tmux/enrichment.ts +0 -241
  105. package/src/daemon/collectors/tmux/index.ts +0 -4
  106. package/src/daemon/collectors/tmux/parse.ts +0 -128
  107. package/src/daemon/collectors/usage.ts +0 -193
  108. package/src/daemon/index.ts +0 -207
  109. package/src/daemon/scheduler.ts +0 -86
  110. package/src/daemon/store.ts +0 -588
  111. package/src/detect.ts +0 -143
  112. package/src/hooks/claude.ts +0 -596
  113. package/src/hooks/codex.ts +0 -517
  114. package/src/legacy/classifier.ts +0 -215
  115. package/src/legacy/detect.ts +0 -143
  116. package/src/mcp/client-server.ts +0 -21
  117. package/src/mcp/http-handler.ts +0 -126
  118. package/src/mcp/index.ts +0 -61
  119. package/src/mcp/resources/config-current.ts +0 -19
  120. package/src/mcp/resources/index.ts +0 -11
  121. package/src/mcp/resources/sessions-active.ts +0 -19
  122. package/src/mcp/resources/sessions-all.ts +0 -19
  123. package/src/mcp/schemas/sessions.ts +0 -3
  124. package/src/mcp/server.ts +0 -17
  125. package/src/mcp/services/api-adapter.ts +0 -48
  126. package/src/mcp/services/data-source.ts +0 -22
  127. package/src/mcp/services/store-adapter.ts +0 -40
  128. package/src/mcp/tools/get-active-sessions.ts +0 -16
  129. package/src/mcp/tools/get-session-children.ts +0 -24
  130. package/src/mcp/tools/get-session-messages.ts +0 -24
  131. package/src/mcp/tools/get-session.ts +0 -23
  132. package/src/mcp/tools/index.ts +0 -15
  133. package/src/mcp/tools/list-sessions.ts +0 -16
  134. package/src/neo-blessed.d.ts +0 -4
  135. package/src/oc/index.ts +0 -302
  136. package/src/service/manager.ts +0 -284
  137. package/src/shared/config.ts +0 -478
  138. package/src/shared/ipc.ts +0 -80
  139. package/src/shared/logger.ts +0 -248
  140. package/src/shared/paths.ts +0 -73
  141. package/src/shared/platform.ts +0 -110
  142. package/src/shared/session-view.ts +0 -241
  143. package/src/shared/types.ts +0 -299
  144. package/src/shared/utils.ts +0 -339
  145. package/src/tui/app.ts +0 -920
  146. package/src/tui/client.ts +0 -149
  147. package/src/tui/index.ts +0 -217
  148. package/src/types.ts +0 -113
  149. package/src/usage/README.md +0 -323
  150. package/src/usage/api.ts +0 -99
  151. package/src/usage/client.ts +0 -3
  152. package/src/usage/clients/anthropic-oauth-usage.ts +0 -178
  153. package/src/usage/clients/anthropic-web-usage.ts +0 -245
  154. package/src/usage/clients/claude-cli-probe.ts +0 -187
  155. package/src/usage/clients/codex-oauth-usage.ts +0 -113
  156. package/src/usage/clients/opencode-go-usage.ts +0 -441
  157. package/src/usage/index.ts +0 -20
  158. package/src/usage/mappers.ts +0 -273
  159. package/src/usage/planner.ts +0 -54
  160. package/src/usage/providers/claude.ts +0 -113
  161. package/src/usage/providers/codex.ts +0 -34
  162. package/src/usage/providers/opencode-go.ts +0 -37
  163. package/src/usage/resolver.ts +0 -4
  164. package/src/usage/resolvers/browser-cookies.ts +0 -470
  165. package/src/usage/resolvers/claude-auth.ts +0 -108
  166. package/src/usage/resolvers/claude-cli.ts +0 -24
  167. package/src/usage/resolvers/claude-desktop-auth.ts +0 -160
  168. package/src/usage/resolvers/claude-web.ts +0 -16
  169. package/src/usage/resolvers/codex-auth.ts +0 -117
  170. package/src/usage/resolvers/opencode-go-web.ts +0 -14
  171. package/src/usage/types.ts +0 -190
@@ -1,248 +0,0 @@
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
- }
@@ -1,73 +0,0 @@
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
- }
@@ -1,110 +0,0 @@
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
- }
@@ -1,241 +0,0 @@
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
- }