@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,215 @@
1
+ import { detectTool, getPreviewMatchPositions, previewSignals } from "./detect";
2
+ import type {
3
+ DetectionReason,
4
+ EnrichedPaneRecord,
5
+ PaneModel,
6
+ PaneSample,
7
+ PaneState,
8
+ ProcessInfo,
9
+ SnapshotHistory,
10
+ ToolKind,
11
+ } from "../shared/types";
12
+ import { cleanSummaryText, compactWhitespace, normalizeCommandName, pickCompletionTail, pickMeaningfulTail, pickQuestionTail, pickSummaryTail } from "../shared/utils";
13
+
14
+ const DEBOUNCE_THRESHOLD = 2;
15
+
16
+ export function classifyPane(input: {
17
+ pane: EnrichedPaneRecord;
18
+ processTree: ProcessInfo[];
19
+ sample: PaneSample | null;
20
+ history?: SnapshotHistory;
21
+ now: number;
22
+ }): { pane: PaneModel; history: SnapshotHistory } {
23
+ const { pane, processTree, sample, history, now } = input;
24
+ const detectedTool = detectTool(pane.currentCommand, processTree);
25
+ const rawState = detectState(detectedTool.tool, pane.currentCommand, processTree, sample);
26
+
27
+ const fingerprint = [
28
+ detectedTool.tool,
29
+ rawState.state,
30
+ processTree.map((process) => `${process.pid}:${process.comm}`).join(","),
31
+ sample?.preview ?? "",
32
+ ].join("|");
33
+
34
+ const lastChangedAt = history?.lastChangedAt ?? now;
35
+ const stableCount = history?.lastFingerprint === fingerprint ? (history.stableCount + 1) : 1;
36
+
37
+ let tool: ToolKind = detectedTool.tool;
38
+ let state: PaneState = rawState.state;
39
+ let stateReasons = rawState.reasons;
40
+ const shouldBypassDebounce = rawState.state === "working"
41
+ && rawState.reasons.some((reason) => (
42
+ reason.label === "activity keywords"
43
+ || reason.label === "opencode footer activity"
44
+ || reason.label === "active agent signals"
45
+ ));
46
+
47
+ if (history && stableCount < DEBOUNCE_THRESHOLD && !shouldBypassDebounce) {
48
+ tool = history.tool;
49
+ state = history.state;
50
+ stateReasons = [
51
+ {
52
+ label: "debounced previous state",
53
+ detail: `holding ${history.state} until new signal stabilizes`,
54
+ },
55
+ ...rawState.reasons,
56
+ ];
57
+ }
58
+
59
+ const historyRecord: SnapshotHistory = {
60
+ tool,
61
+ state,
62
+ lastFingerprint: fingerprint,
63
+ lastChangedAt: ((stableCount === DEBOUNCE_THRESHOLD && history?.state !== state) || (shouldBypassDebounce && stableCount === 1)) ? now : lastChangedAt,
64
+ stableCount,
65
+ };
66
+
67
+ const processChain = processTree.map((process) => compactWhitespace(process.comm || normalizeCommandName(process.command)));
68
+ const summary = summarizeState(state, stateReasons, sample, processChain, pane.currentCommand);
69
+
70
+ return {
71
+ pane: {
72
+ ...pane,
73
+ processTree,
74
+ processChain,
75
+ sample,
76
+ tool,
77
+ state,
78
+ toolReasons: detectedTool.reasons,
79
+ stateReasons,
80
+ summary,
81
+ lastChangedAt: historyRecord.lastChangedAt,
82
+ stableForMs: Math.max(0, now - historyRecord.lastChangedAt),
83
+ hasAttention: state === "waiting-input",
84
+ },
85
+ history: historyRecord,
86
+ };
87
+ }
88
+
89
+ export function detectState(tool: ToolKind, currentCommand: string, processTree: ProcessInfo[], sample: PaneSample | null): { state: PaneState; reasons: DetectionReason[] } {
90
+ const reasons: DetectionReason[] = [];
91
+ const promptSignals = previewSignals(sample);
92
+ const previewMatches = getPreviewMatchPositions(sample);
93
+ const processStates = processTree.map((process) => process.state).join(",");
94
+ const commands = [currentCommand, ...processTree.map((process) => process.command)].join(" ");
95
+ const meaningfulPreview = sample ? pickMeaningfulTail(sample.lines, sample.preview) : "";
96
+
97
+ if (tool === "shell") {
98
+ reasons.push({ label: "shell pane", detail: compactWhitespace(currentCommand || processTree[0]?.command || "shell") });
99
+ return { state: "shell", reasons };
100
+ }
101
+
102
+ if (tool === "unknown") {
103
+ reasons.push({ label: "insufficient classification signals", detail: compactWhitespace(commands) || "unknown workload" });
104
+ return { state: "unknown", reasons };
105
+ }
106
+
107
+ const activityReason = promptSignals.find((reason) => reason.label === "activity keywords");
108
+ const footerReason = promptSignals.find((reason) => reason.label === "opencode footer activity");
109
+ const waitingReason = promptSignals.find((reason) => {
110
+ if (reason.label !== "input prompt keywords" && reason.label !== "prompt-like tail") {
111
+ return false;
112
+ }
113
+
114
+ return !/^>\s*implement\s*\{feature\}/i.test(reason.detail);
115
+ });
116
+ const completionReason = promptSignals.find((reason) => reason.label === "completion keywords");
117
+ const activeKeywords = /(thinking|generating|executing|applying|running|streaming|analyzing|diff|patch|searching|reading)/i;
118
+ const strongWorkingSignal = Boolean(
119
+ (activityReason && /(working\s*\(|background terminal running|waited for background terminal|created\s+\d+)/i.test(activityReason.detail))
120
+ || (tool === "opencode" && footerReason),
121
+ );
122
+ const hasRunnableProcessSignal = processTree.some((process) => /[RU]/.test(process.state));
123
+ const hasActiveProcessSignal = hasRunnableProcessSignal;
124
+ const hasFreshActivitySignal = Boolean(
125
+ previewMatches.activityIndex >= Math.max(previewMatches.promptIndex, previewMatches.completionIndex)
126
+ && (
127
+ (activityReason && (cleanSummaryText(activityReason.detail) === meaningfulPreview || activeKeywords.test(meaningfulPreview)))
128
+ || (tool === "opencode" && footerReason)
129
+ ),
130
+ );
131
+
132
+ if (completionReason && !hasActiveProcessSignal && previewMatches.completionIndex >= previewMatches.activityIndex) {
133
+ reasons.push({
134
+ label: "completed work detected",
135
+ detail: completionReason.detail,
136
+ });
137
+ // For Codex, generic prompt patterns (e.g. ›) are normal UI — not a
138
+ // question from the agent. Only detectCodexPermissionPrompt can determine
139
+ // waiting-input for Codex.
140
+ const isCodexWaiting = tool === "codex" ? false : Boolean(waitingReason);
141
+ return { state: isCodexWaiting ? "waiting-input" : "idle", reasons: isCodexWaiting ? [...reasons, waitingReason!] : reasons };
142
+ }
143
+
144
+ if (strongWorkingSignal || hasFreshActivitySignal) {
145
+ if (footerReason && tool === "opencode") {
146
+ reasons.push(footerReason);
147
+ } else if (activityReason) {
148
+ reasons.push(activityReason);
149
+ }
150
+ return { state: "working", reasons };
151
+ }
152
+
153
+ if (waitingReason) {
154
+ if (tool === "codex") {
155
+ // Codex › prompt is normal UI, not a question from the agent.
156
+ // Only detectCodexPermissionPrompt can determine waiting-input for codex.
157
+ return { state: "idle", reasons: [...reasons, waitingReason, { label: "codex prompt is normal ui", detail: "› is not a question" }] };
158
+ }
159
+ reasons.push(waitingReason);
160
+ return { state: "waiting-input", reasons };
161
+ }
162
+
163
+ if (hasActiveProcessSignal) {
164
+ reasons.push(footerReason ?? activityReason ?? { label: "active agent signals", detail: meaningfulPreview || processStates || compactWhitespace(commands) });
165
+ return { state: "working", reasons };
166
+ }
167
+
168
+ reasons.push({ label: "agent present without active signals", detail: meaningfulPreview || compactWhitespace(commands) || tool });
169
+ return { state: "idle", reasons };
170
+ }
171
+
172
+ function summarizeState(
173
+ state: PaneState,
174
+ stateReasons: DetectionReason[],
175
+ sample: PaneSample | null,
176
+ processChain: string[],
177
+ currentCommand: string,
178
+ ): string {
179
+ const primaryReason = stateReasons.find((reason) => reason.label !== "debounced previous state")?.detail;
180
+ const hasCompletionReason = stateReasons.some((reason) => reason.label === "completed work detected" || reason.label === "completion keywords");
181
+ const preferredSummary = state === "waiting-input"
182
+ ? pickQuestionTail(sample?.lines ?? [], primaryReason ?? "")
183
+ : hasCompletionReason
184
+ ? pickCompletionTail(sample?.lines ?? [], primaryReason ?? "")
185
+ : pickSummaryTail(sample?.lines ?? [], primaryReason ?? "");
186
+
187
+ if (primaryReason && primaryReason.length > 0 && primaryReason === preferredSummary) {
188
+ return primaryReason;
189
+ }
190
+
191
+ if (sample?.lines.length) {
192
+ const meaningful = state === "waiting-input"
193
+ ? pickQuestionTail(sample.lines, sample.preview)
194
+ : hasCompletionReason
195
+ ? pickCompletionTail(sample.lines, sample.preview)
196
+ : pickSummaryTail(sample.lines, sample.preview);
197
+ if (meaningful) {
198
+ return meaningful;
199
+ }
200
+ }
201
+
202
+ if (sample?.preview) {
203
+ return sample.preview;
204
+ }
205
+
206
+ if (processChain.length > 0) {
207
+ return processChain.join(" -> ");
208
+ }
209
+
210
+ if (currentCommand) {
211
+ return currentCommand;
212
+ }
213
+
214
+ return state;
215
+ }
@@ -0,0 +1,143 @@
1
+ import type { DetectionReason, PaneSample, ProcessInfo, ToolKind } from "../shared/types";
2
+ import { compactWhitespace, isMostlyUiNoise, isOpencodeFooterLine, isSuggestionStyleText, pickMeaningfulTail, stripAnsiAndUi } from "../shared/utils";
3
+
4
+ const ACTIVITY_PATTERN = /(thinking|generating|executing|applying|running|streaming|analyzing|^working\s*\(|background terminal running|waited for background terminal|creating|created\s+\d+)/i;
5
+ const PROMPT_PATTERN = /(press enter|continue\?|y\/n|confirm(?:a|as|ar)?\b|waiting for input|type your own answer|send a message|^>\s|^>\b|^\?\s|pregunta clave|^\-\s+¿|^¿)/im;
6
+ const COMPLETION_PATTERN = /(worked for\s+\d|all done|completed|finished|done\b|se cre[oó]\b|cada archivo contiene|ya se escribieron\s+\d+\s+a\s+\d+|contin[uú]o hasta que termine|proceso ya pas[oó]|termin[oó]\s+bien)/i;
7
+
8
+ const TOOL_PATTERNS: Array<{ tool: ToolKind; regex: RegExp; label: string }> = [
9
+ { tool: "opencode", regex: /(^|\b)(opencode)(\b|$)/i, label: "process matched opencode" },
10
+ { tool: "claude", regex: /(?:^|\s|\/)(claude)(?:\s|$)/i, label: "process matched claude" },
11
+ { tool: "codex", regex: /(^|\b)(codexcli|codex)(\b|$)/i, label: "process matched codex" },
12
+ { tool: "copilotcli", regex: /(^|\b)(copilotcli|copilot)(\b|$)/i, label: "process matched copilot" },
13
+ ];
14
+
15
+ const SHELL_PATTERN = /(^|\b)(bash|zsh|fish|sh|tmux)(\b|$)/i;
16
+
17
+ export function detectTool(currentCommand: string, processTree: ProcessInfo[]): { tool: ToolKind; reasons: DetectionReason[] } {
18
+ const reasons: DetectionReason[] = [];
19
+ const candidates = [currentCommand, ...processTree.map((process) => `${process.comm} ${process.command}`)];
20
+
21
+ for (const candidate of candidates) {
22
+ for (const pattern of TOOL_PATTERNS) {
23
+ if (pattern.regex.test(candidate)) {
24
+ reasons.push({
25
+ label: pattern.label,
26
+ detail: compactWhitespace(candidate),
27
+ });
28
+ return { tool: pattern.tool, reasons };
29
+ }
30
+ }
31
+ }
32
+
33
+ if (candidates.some((candidate) => SHELL_PATTERN.test(candidate))) {
34
+ reasons.push({
35
+ label: "shell foreground detected",
36
+ detail: compactWhitespace(currentCommand || processTree[0]?.command || "shell"),
37
+ });
38
+ return { tool: "shell", reasons };
39
+ }
40
+
41
+ reasons.push({
42
+ label: "no supported tool match",
43
+ detail: compactWhitespace(candidates.join(" | ")) || "no command context",
44
+ });
45
+ return { tool: "unknown", reasons };
46
+ }
47
+
48
+ export function previewSignals(sample: PaneSample | null): DetectionReason[] {
49
+ if (!sample || sample.lines.length === 0) {
50
+ return [];
51
+ }
52
+
53
+ const normalizedLines = sample.lines.map((line) => compactWhitespace(stripAnsiAndUi(line))).filter(Boolean);
54
+ const recentLines = normalizedLines.slice(-8);
55
+ const tailWindow = recentLines.filter((line) => !isMostlyUiNoise(line));
56
+ const preview = pickMeaningfulTail(tailWindow, sample.preview);
57
+ const reasons: DetectionReason[] = [];
58
+ const activityMatch = findMatchingLine(tailWindow, ACTIVITY_PATTERN);
59
+ const promptMatch = findMatchingLine(tailWindow, PROMPT_PATTERN, { excludeSuggestions: true });
60
+ const completionMatch = findMatchingLine(tailWindow, COMPLETION_PATTERN);
61
+ const opencodeFooterMatch = findOpencodeFooterSignal(recentLines);
62
+
63
+ if (activityMatch) {
64
+ reasons.push({ label: "activity keywords", detail: activityMatch });
65
+ }
66
+
67
+ if (promptMatch) {
68
+ reasons.push({ label: "input prompt keywords", detail: promptMatch });
69
+ }
70
+
71
+ if (completionMatch) {
72
+ reasons.push({ label: "completion keywords", detail: completionMatch });
73
+ }
74
+
75
+ if (opencodeFooterMatch) {
76
+ reasons.push({ label: "opencode footer activity", detail: opencodeFooterMatch });
77
+ }
78
+
79
+ const lastMeaningful = tailWindow.at(-1) ?? preview;
80
+ if (!isSuggestionStyleText(lastMeaningful) && (/[:>]\s*$/.test(lastMeaningful) || /^>\s+/.test(lastMeaningful) || /^\?\s+/.test(lastMeaningful))) {
81
+ reasons.push({ label: "prompt-like tail", detail: lastMeaningful });
82
+ }
83
+
84
+ return reasons;
85
+ }
86
+
87
+ export function getPreviewMatchPositions(sample: PaneSample | null): { activityIndex: number; promptIndex: number; completionIndex: number } {
88
+ if (!sample || sample.lines.length === 0) {
89
+ return { activityIndex: -1, promptIndex: -1, completionIndex: -1 };
90
+ }
91
+
92
+ const normalizedLines = sample.lines.map((line) => compactWhitespace(stripAnsiAndUi(line)));
93
+ let activityIndex = -1;
94
+ let promptIndex = -1;
95
+ let completionIndex = -1;
96
+
97
+ for (let index = 0; index < normalizedLines.length; index += 1) {
98
+ const line = normalizedLines[index] ?? "";
99
+ if (!line) {
100
+ continue;
101
+ }
102
+ if (ACTIVITY_PATTERN.test(line) || isOpencodeFooterLine(line)) {
103
+ activityIndex = index;
104
+ }
105
+ if (isMostlyUiNoise(line) && !isOpencodeFooterLine(line)) {
106
+ continue;
107
+ }
108
+ if (PROMPT_PATTERN.test(line) && !isSuggestionStyleText(line)) {
109
+ promptIndex = index;
110
+ }
111
+ if (COMPLETION_PATTERN.test(line)) {
112
+ completionIndex = index;
113
+ }
114
+ }
115
+
116
+ return { activityIndex, promptIndex, completionIndex };
117
+ }
118
+
119
+ function findMatchingLine(lines: string[], pattern: RegExp, options?: { excludeSuggestions?: boolean }): string | null {
120
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
121
+ const line = lines[index] ?? "";
122
+ if (options?.excludeSuggestions && isSuggestionStyleText(line)) {
123
+ continue;
124
+ }
125
+ if (pattern.test(line)) {
126
+ return line;
127
+ }
128
+ }
129
+
130
+ return null;
131
+ }
132
+
133
+ function findOpencodeFooterSignal(lines: string[]): string | null {
134
+ const tailLines = lines.slice(-3);
135
+ for (let index = tailLines.length - 1; index >= 0; index -= 1) {
136
+ const line = tailLines[index] ?? "";
137
+ if (isOpencodeFooterLine(line)) {
138
+ return line;
139
+ }
140
+ }
141
+
142
+ return null;
143
+ }
@@ -0,0 +1,21 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { registerAllTools } from "./tools";
3
+ import { registerAllResources } from "./resources";
4
+ import { createApiAdapter } from "./services/api-adapter";
5
+
6
+ type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
7
+
8
+ /**
9
+ * Create an MCP server that fetches data from the daemon API over IPC.
10
+ * Used by the stdio binary (scrylog-mcp).
11
+ */
12
+ export function createMcpServerFromApi(daemonFetch: FetchFn): McpServer {
13
+ const server = new McpServer({
14
+ name: "scrylog",
15
+ version: "0.2.0",
16
+ });
17
+ const ds = createApiAdapter(daemonFetch);
18
+ registerAllTools(server, ds);
19
+ registerAllResources(server, ds);
20
+ return server;
21
+ }
@@ -0,0 +1,126 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { randomUUID, timingSafeEqual } from "node:crypto";
3
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
4
+ import type { DaemonStore } from "../daemon/store";
5
+ import type { ScrylogConfig } from "../shared/config";
6
+ import { createMcpServer } from "./server";
7
+
8
+ function secureCompare(a: string, b: string): boolean {
9
+ const bufA = Buffer.from(a, "utf-8");
10
+ const bufB = Buffer.from(b, "utf-8");
11
+ if (bufA.length !== bufB.length) return false;
12
+ return timingSafeEqual(bufA, bufB);
13
+ }
14
+
15
+ const CORS_HEADERS_UNSET = {} as Record<string, string>;
16
+
17
+ function buildCorsHeaders(config: ScrylogConfig): Record<string, string> {
18
+ if (!config.cors.enabled) return CORS_HEADERS_UNSET;
19
+ const origins = config.cors.origins.join(", ");
20
+ const headers: Record<string, string> = {
21
+ "Access-Control-Allow-Origin": origins,
22
+ "Access-Control-Allow-Methods": config.cors.methods.join(", "),
23
+ "Access-Control-Allow-Headers": config.cors.allowedHeaders.join(", "),
24
+ };
25
+ if (config.cors.credentials) {
26
+ headers["Access-Control-Allow-Credentials"] = "true";
27
+ }
28
+ if (config.cors.maxAge) {
29
+ headers["Access-Control-Max-Age"] = String(config.cors.maxAge);
30
+ }
31
+ if (config.cors.exposedHeaders.length > 0) {
32
+ headers["Access-Control-Expose-Headers"] = config.cors.exposedHeaders.join(", ");
33
+ }
34
+ return headers;
35
+ }
36
+
37
+ /**
38
+ * Create the MCP HTTP handler for in-daemon streaming HTTP transport.
39
+ * Returns null if no MCP port is configured.
40
+ */
41
+ export function createMcpHttpHandler(
42
+ store: DaemonStore,
43
+ config: ScrylogConfig,
44
+ ): ((request: Request) => Response | Promise<Response>) | null {
45
+ if (!config.mcp.httpEnabled || !config.mcp.port) return null;
46
+
47
+ const { WebStandardStreamableHTTPServerTransport } = require("@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js");
48
+ const sessions = new Map<string, { transport: InstanceType<typeof WebStandardStreamableHTTPServerTransport>; server: McpServer }>();
49
+
50
+ const bearerToken = config.mcp.bearerToken;
51
+ const corsHeaders = buildCorsHeaders(config);
52
+
53
+ return async (request: Request): Promise<Response> => {
54
+ // CORS preflight
55
+ if (request.method === "OPTIONS" && config.cors.enabled) {
56
+ return new Response(null, { status: 204, headers: corsHeaders });
57
+ }
58
+
59
+ // Bearer token auth
60
+ if (bearerToken) {
61
+ const auth = request.headers.get("Authorization");
62
+ if (!auth || !secureCompare(auth, `Bearer ${bearerToken}`)) {
63
+ return new Response(JSON.stringify({ error: "Unauthorized" }), {
64
+ status: 401,
65
+ headers: { "Content-Type": "application/json", ...corsHeaders },
66
+ });
67
+ }
68
+ }
69
+
70
+ const sessionId = request.headers.get("mcp-session-id") ?? undefined;
71
+
72
+ if (sessionId) {
73
+ const existing = sessions.get(sessionId);
74
+ if (!existing) {
75
+ return new Response(JSON.stringify({
76
+ jsonrpc: "2.0",
77
+ error: { code: -32000, message: "Bad Request: No valid session ID provided" },
78
+ id: null,
79
+ }), {
80
+ status: 400,
81
+ headers: { "Content-Type": "application/json" },
82
+ });
83
+ }
84
+
85
+ return existing.transport.handleRequest(request);
86
+ }
87
+
88
+ let body: unknown;
89
+ try {
90
+ body = await request.clone().json();
91
+ } catch {
92
+ body = undefined;
93
+ }
94
+
95
+ if (!body || !isInitializeRequest(body)) {
96
+ return new Response(JSON.stringify({
97
+ jsonrpc: "2.0",
98
+ error: { code: -32000, message: "Bad Request: No valid session ID provided" },
99
+ id: null,
100
+ }), {
101
+ status: 400,
102
+ headers: { "Content-Type": "application/json" },
103
+ });
104
+ }
105
+
106
+ let transport: InstanceType<typeof WebStandardStreamableHTTPServerTransport> | undefined;
107
+ const server = createMcpServer(store, config);
108
+ transport = new WebStandardStreamableHTTPServerTransport({
109
+ sessionIdGenerator: () => randomUUID(),
110
+ enableJsonResponse: true,
111
+ onsessioninitialized: (newSessionId: string) => {
112
+ sessions.set(newSessionId, { transport: transport!, server });
113
+ },
114
+ onsessionclosed: (closedSessionId: string) => {
115
+ const existing = sessions.get(closedSessionId);
116
+ if (existing) {
117
+ void existing.server.close();
118
+ sessions.delete(closedSessionId);
119
+ }
120
+ },
121
+ });
122
+
123
+ await server.connect(transport);
124
+ return transport.handleRequest(request);
125
+ };
126
+ }
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bun
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { loadConfig } from "../shared/config";
4
+ import { createDaemonFetch, getSocketPath } from "../shared/ipc";
5
+ import { IS_WINDOWS } from "../shared/platform";
6
+ import { createMcpServerFromApi } from "./client-server";
7
+
8
+ function printHelp() {
9
+ process.stdout.write(`scrylog-mcp
10
+
11
+ Usage:
12
+ scrylog-mcp [--config <path>]
13
+
14
+ Options:
15
+ --config <path> Load config from an explicit path
16
+ -h, --help Show this help text
17
+
18
+ Notes:
19
+ Starts the stdio MCP server and proxies data from scrylogd.
20
+ The daemon must already be running and reachable.
21
+ `);
22
+ }
23
+
24
+ const args = process.argv.slice(2);
25
+ if (args.includes("--help") || args.includes("-h")) {
26
+ printHelp();
27
+ process.exit(0);
28
+ }
29
+ const configFlagIndex = args.indexOf("--config");
30
+ const configPath = configFlagIndex >= 0 ? args[configFlagIndex + 1] : undefined;
31
+ const config = loadConfig(configPath);
32
+
33
+ if (!config.mcp.stdioEnabled) {
34
+ process.stderr.write("[scrylog-mcp] MCP stdio is disabled in config.\n");
35
+ process.exit(1);
36
+ }
37
+
38
+ const socketPath = IS_WINDOWS ? undefined : getSocketPath(config.daemon.socketPath);
39
+ const daemonFetch = createDaemonFetch({
40
+ host: config.api.host,
41
+ port: config.api.port,
42
+ socketPath,
43
+ bearerToken: config.api.bearerToken,
44
+ });
45
+
46
+ // Verify daemon is reachable
47
+ try {
48
+ const health = await daemonFetch("/api/health");
49
+ if (!health.ok) {
50
+ process.stderr.write(`[scrylog-mcp] Daemon returned ${health.status}. Is scrylogd running?\n`);
51
+ process.exit(1);
52
+ }
53
+ } catch (error) {
54
+ const msg = error instanceof Error ? error.message : String(error);
55
+ process.stderr.write(`[scrylog-mcp] Cannot connect to daemon: ${msg}\nIs scrylogd running?\n`);
56
+ process.exit(1);
57
+ }
58
+
59
+ const mcp = createMcpServerFromApi(daemonFetch);
60
+ const transport = new StdioServerTransport();
61
+ await mcp.connect(transport);
@@ -0,0 +1,19 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { DataSource } from "../services/data-source";
3
+
4
+ export function registerConfigCurrent(server: McpServer, ds: DataSource) {
5
+ server.resource(
6
+ "config://current",
7
+ "Current daemon configuration (redacted)",
8
+ async () => {
9
+ const config = await ds.fetchConfig();
10
+ return {
11
+ contents: [{
12
+ uri: "config://current",
13
+ text: JSON.stringify(config, null, 2),
14
+ mimeType: "application/json",
15
+ }],
16
+ };
17
+ },
18
+ );
19
+ }
@@ -0,0 +1,11 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { DataSource } from "../services/data-source";
3
+ import { registerSessionsAll } from "./sessions-all";
4
+ import { registerSessionsActive } from "./sessions-active";
5
+ import { registerConfigCurrent } from "./config-current";
6
+
7
+ export function registerAllResources(server: McpServer, ds: DataSource) {
8
+ registerSessionsAll(server, ds);
9
+ registerSessionsActive(server, ds);
10
+ registerConfigCurrent(server, ds);
11
+ }
@@ -0,0 +1,19 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { DataSource } from "../services/data-source";
3
+
4
+ export function registerSessionsActive(server: McpServer, ds: DataSource) {
5
+ server.resource(
6
+ "sessions://active",
7
+ "Active sessions (working or waiting-input)",
8
+ async () => {
9
+ const sessions = await ds.fetchActiveSessions();
10
+ return {
11
+ contents: [{
12
+ uri: "sessions://active",
13
+ text: JSON.stringify(sessions, null, 2),
14
+ mimeType: "application/json",
15
+ }],
16
+ };
17
+ },
18
+ );
19
+ }
@@ -0,0 +1,19 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { DataSource } from "../services/data-source";
3
+
4
+ export function registerSessionsAll(server: McpServer, ds: DataSource) {
5
+ server.resource(
6
+ "sessions://all",
7
+ "All monitored sessions as a tree (root sessions with children arrays)",
8
+ async () => {
9
+ const sessions = await ds.fetchSessions();
10
+ return {
11
+ contents: [{
12
+ uri: "sessions://all",
13
+ text: JSON.stringify(sessions, null, 2),
14
+ mimeType: "application/json",
15
+ }],
16
+ };
17
+ },
18
+ );
19
+ }
@@ -0,0 +1,3 @@
1
+ import { z } from "zod";
2
+
3
+ export const SessionIdSchema = z.string().describe("Session ID");
@@ -0,0 +1,17 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { DaemonStore } from "../daemon/store";
3
+ import type { ScrylogConfig } from "../shared/config";
4
+ import { registerAllTools } from "./tools";
5
+ import { registerAllResources } from "./resources";
6
+ import { createStoreAdapter } from "./services/store-adapter";
7
+
8
+ export function createMcpServer(store: DaemonStore, config: ScrylogConfig): McpServer {
9
+ const server = new McpServer({
10
+ name: "scrylog",
11
+ version: "0.2.0",
12
+ });
13
+ const ds = createStoreAdapter(store, config);
14
+ registerAllTools(server, ds);
15
+ registerAllResources(server, ds);
16
+ return server;
17
+ }