@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.
- package/LICENSE +21 -0
- package/README.md +836 -0
- package/package.json +75 -0
- package/src/api/docs/sessions.keys.ts +6 -0
- package/src/api/docs/sessions.ts +81 -0
- package/src/api/docs/system.keys.ts +5 -0
- package/src/api/docs/system.ts +66 -0
- package/src/api/docs/usage.keys.ts +3 -0
- package/src/api/docs/usage.ts +24 -0
- package/src/api/docs/webhooks.keys.ts +5 -0
- package/src/api/docs/webhooks.ts +78 -0
- package/src/api/index.ts +18 -0
- package/src/api/lib/auth.ts +43 -0
- package/src/api/lib/errors.ts +28 -0
- package/src/api/lib/route.ts +18 -0
- package/src/api/models/claude.ts +4 -0
- package/src/api/models/codex.ts +4 -0
- package/src/api/models/opencode.ts +11 -0
- package/src/api/routes/sessions.ts +47 -0
- package/src/api/routes/system.ts +103 -0
- package/src/api/routes/usage.ts +15 -0
- package/src/api/routes/webhooks/claude.ts +161 -0
- package/src/api/routes/webhooks/codex.ts +175 -0
- package/src/api/routes/webhooks/opencode.ts +107 -0
- package/src/api/routes.ts +93 -0
- package/src/api/schemas/common.ts +21 -0
- package/src/api/schemas/sessions.ts +72 -0
- package/src/api/schemas/usage.ts +50 -0
- package/src/api/schemas/webhooks/claude.ts +32 -0
- package/src/api/schemas/webhooks/codex.ts +34 -0
- package/src/api/schemas/webhooks/opencode.ts +29 -0
- package/src/api/services/claude-session.ts +9 -0
- package/src/api/services/codex-session.ts +8 -0
- package/src/api/services/opencode-session.ts +73 -0
- package/src/classifier.ts +206 -0
- package/src/cli/commands.ts +498 -0
- package/src/cli/prompt.ts +44 -0
- package/src/daemon/cli.ts +27 -0
- package/src/daemon/collectors/claude/README.md +108 -0
- package/src/daemon/collectors/claude/collector.ts +413 -0
- package/src/daemon/collectors/claude/discovery.ts +148 -0
- package/src/daemon/collectors/claude/hooks.ts +181 -0
- package/src/daemon/collectors/claude/index.ts +8 -0
- package/src/daemon/collectors/claude/jsonl.ts +85 -0
- package/src/daemon/collectors/claude/transcript.ts +246 -0
- package/src/daemon/collectors/codex/README.md +147 -0
- package/src/daemon/collectors/codex/collector.ts +661 -0
- package/src/daemon/collectors/codex/discovery.ts +104 -0
- package/src/daemon/collectors/codex/hooks.ts +179 -0
- package/src/daemon/collectors/codex/index.ts +7 -0
- package/src/daemon/collectors/codex/jsonl.ts +108 -0
- package/src/daemon/collectors/codex/transcript.ts +299 -0
- package/src/daemon/collectors/git.ts +295 -0
- package/src/daemon/collectors/opencode/README.md +106 -0
- package/src/daemon/collectors/opencode/collector.ts +543 -0
- package/src/daemon/collectors/opencode/discovery.ts +257 -0
- package/src/daemon/collectors/opencode/index.ts +5 -0
- package/src/daemon/collectors/opencode/session-builder.ts +59 -0
- package/src/daemon/collectors/opencode-db.ts +282 -0
- package/src/daemon/collectors/process.ts +201 -0
- package/src/daemon/collectors/tmux/README.md +128 -0
- package/src/daemon/collectors/tmux/collector.ts +472 -0
- package/src/daemon/collectors/tmux/enrichment.ts +241 -0
- package/src/daemon/collectors/tmux/index.ts +4 -0
- package/src/daemon/collectors/tmux/parse.ts +128 -0
- package/src/daemon/collectors/usage.ts +193 -0
- package/src/daemon/index.ts +207 -0
- package/src/daemon/scheduler.ts +86 -0
- package/src/daemon/store.ts +535 -0
- package/src/detect.ts +143 -0
- package/src/hooks/claude.ts +596 -0
- package/src/hooks/codex.ts +517 -0
- package/src/legacy/classifier.ts +215 -0
- package/src/legacy/detect.ts +143 -0
- package/src/mcp/client-server.ts +21 -0
- package/src/mcp/http-handler.ts +126 -0
- package/src/mcp/index.ts +61 -0
- package/src/mcp/resources/config-current.ts +19 -0
- package/src/mcp/resources/index.ts +11 -0
- package/src/mcp/resources/sessions-active.ts +19 -0
- package/src/mcp/resources/sessions-all.ts +19 -0
- package/src/mcp/schemas/sessions.ts +3 -0
- package/src/mcp/server.ts +17 -0
- package/src/mcp/services/api-adapter.ts +48 -0
- package/src/mcp/services/data-source.ts +22 -0
- package/src/mcp/services/store-adapter.ts +40 -0
- package/src/mcp/tools/get-active-sessions.ts +16 -0
- package/src/mcp/tools/get-session-children.ts +24 -0
- package/src/mcp/tools/get-session-messages.ts +24 -0
- package/src/mcp/tools/get-session.ts +23 -0
- package/src/mcp/tools/index.ts +15 -0
- package/src/mcp/tools/list-sessions.ts +16 -0
- package/src/neo-blessed.d.ts +4 -0
- package/src/oc/index.ts +302 -0
- package/src/service/manager.ts +242 -0
- package/src/shared/config.ts +478 -0
- package/src/shared/ipc.ts +80 -0
- package/src/shared/logger.ts +248 -0
- package/src/shared/paths.ts +73 -0
- package/src/shared/platform.ts +110 -0
- package/src/shared/session-view.ts +241 -0
- package/src/shared/types.ts +299 -0
- package/src/shared/utils.ts +339 -0
- package/src/tui/app.ts +920 -0
- package/src/tui/client.ts +149 -0
- package/src/tui/index.ts +181 -0
- package/src/types.ts +113 -0
- package/src/usage/README.md +323 -0
- package/src/usage/api.ts +99 -0
- package/src/usage/client.ts +3 -0
- package/src/usage/clients/anthropic-oauth-usage.ts +178 -0
- package/src/usage/clients/anthropic-web-usage.ts +245 -0
- package/src/usage/clients/claude-cli-probe.ts +187 -0
- package/src/usage/clients/codex-oauth-usage.ts +113 -0
- package/src/usage/clients/opencode-go-usage.ts +441 -0
- package/src/usage/index.ts +19 -0
- package/src/usage/mappers.ts +273 -0
- package/src/usage/planner.ts +54 -0
- package/src/usage/providers/claude.ts +113 -0
- package/src/usage/providers/codex.ts +34 -0
- package/src/usage/providers/opencode-go.ts +37 -0
- package/src/usage/resolver.ts +4 -0
- package/src/usage/resolvers/browser-cookies.ts +198 -0
- package/src/usage/resolvers/claude-auth.ts +99 -0
- package/src/usage/resolvers/claude-cli.ts +24 -0
- package/src/usage/resolvers/claude-web.ts +16 -0
- package/src/usage/resolvers/codex-auth.ts +117 -0
- package/src/usage/resolvers/opencode-go-web.ts +14 -0
- package/src/usage/types.ts +190 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// ── Legacy types (preserved for backward compat with collectors/classifier) ────
|
|
2
|
+
|
|
3
|
+
export type ToolKind = "opencode" | "claude" | "codex" | "copilotcli" | "shell" | "unknown";
|
|
4
|
+
|
|
5
|
+
export type PaneState = "working" | "idle" | "waiting-input" | "shell" | "unknown";
|
|
6
|
+
|
|
7
|
+
export interface SessionInfo {
|
|
8
|
+
name: string;
|
|
9
|
+
attached: boolean;
|
|
10
|
+
windows: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TmuxPaneRecord {
|
|
14
|
+
sessionName: string;
|
|
15
|
+
windowIndex: number;
|
|
16
|
+
windowName: string;
|
|
17
|
+
paneIndex: number;
|
|
18
|
+
paneId: string;
|
|
19
|
+
panePid: number;
|
|
20
|
+
currentCommand: string;
|
|
21
|
+
currentPath: string;
|
|
22
|
+
active: boolean;
|
|
23
|
+
tty: string;
|
|
24
|
+
title: string;
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
/** OpenCode session ID written by the scrylog plugin via @opencode-session pane option. */
|
|
28
|
+
opencodeSession?: string;
|
|
29
|
+
/** Claude session ID written by the scrylog hook via @claude-session pane option. */
|
|
30
|
+
claudeSession?: string;
|
|
31
|
+
/** Codex session ID written by the scrylog hook via @codex-session pane option. */
|
|
32
|
+
codexSession?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ProjectInfo {
|
|
36
|
+
displayName: string;
|
|
37
|
+
compactLabel: string;
|
|
38
|
+
sourcePath: string;
|
|
39
|
+
isGitRepository: boolean;
|
|
40
|
+
gitRootPath?: string;
|
|
41
|
+
repositoryIdentityPath?: string;
|
|
42
|
+
branch?: string;
|
|
43
|
+
isWorktree?: boolean;
|
|
44
|
+
remoteName?: string;
|
|
45
|
+
remoteUrl?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface EnrichedPaneRecord extends TmuxPaneRecord {
|
|
49
|
+
project: ProjectInfo;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ProcessInfo {
|
|
53
|
+
pid: number;
|
|
54
|
+
ppid: number;
|
|
55
|
+
state: string;
|
|
56
|
+
elapsed: string;
|
|
57
|
+
command: string;
|
|
58
|
+
comm: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface DetectionReason {
|
|
62
|
+
label: string;
|
|
63
|
+
detail: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface PaneSample {
|
|
67
|
+
paneId: string;
|
|
68
|
+
lines: string[];
|
|
69
|
+
preview: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface PaneModel extends EnrichedPaneRecord {
|
|
73
|
+
processTree: ProcessInfo[];
|
|
74
|
+
processChain: string[];
|
|
75
|
+
sample: PaneSample | null;
|
|
76
|
+
tool: ToolKind;
|
|
77
|
+
state: PaneState;
|
|
78
|
+
toolReasons: DetectionReason[];
|
|
79
|
+
stateReasons: DetectionReason[];
|
|
80
|
+
summary: string;
|
|
81
|
+
lastChangedAt: number;
|
|
82
|
+
stableForMs: number;
|
|
83
|
+
hasAttention: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SnapshotError {
|
|
87
|
+
source: string;
|
|
88
|
+
message: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface DashboardSnapshot {
|
|
92
|
+
sessions: SessionInfo[];
|
|
93
|
+
panes: PaneModel[];
|
|
94
|
+
generatedAt: number;
|
|
95
|
+
errors: SnapshotError[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface FilterOptions {
|
|
99
|
+
query: string;
|
|
100
|
+
actionableOnly: boolean;
|
|
101
|
+
tool: ToolKind | "all";
|
|
102
|
+
state: PaneState | "all";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Re-export usage types so consumers can import from shared/types
|
|
106
|
+
export type {
|
|
107
|
+
UsageState,
|
|
108
|
+
UsageProviderEntry,
|
|
109
|
+
UsageWindow,
|
|
110
|
+
ProviderCostSnapshot,
|
|
111
|
+
} from "../usage/types";
|
|
112
|
+
|
|
113
|
+
export interface SnapshotHistory {
|
|
114
|
+
tool: ToolKind;
|
|
115
|
+
state: PaneState;
|
|
116
|
+
lastFingerprint: string;
|
|
117
|
+
lastChangedAt: number;
|
|
118
|
+
stableCount: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface CollectorOptions {
|
|
122
|
+
previewLines: number;
|
|
123
|
+
detailLines: number;
|
|
124
|
+
refreshMs: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface AppOptions extends CollectorOptions {
|
|
128
|
+
selectedPaneId?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── New unified session model ─────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/** How the agent session was detected */
|
|
134
|
+
export type SessionSource = "tmux" | "standalone";
|
|
135
|
+
|
|
136
|
+
/** The tool type — extended with "claude" and "error" variants */
|
|
137
|
+
export type SessionTool = "opencode" | "claude" | "codex" | "copilotcli" | "shell" | "unknown";
|
|
138
|
+
|
|
139
|
+
/** The current state of the session */
|
|
140
|
+
export type SessionState = "working" | "idle" | "waiting-input" | "error" | "shell" | "unknown";
|
|
141
|
+
|
|
142
|
+
/** OpenCode-specific session metadata */
|
|
143
|
+
export interface OpenCodeSessionMeta {
|
|
144
|
+
sessionId: string;
|
|
145
|
+
instanceUrl: string;
|
|
146
|
+
/** Number of pending permission/question prompts not yet replied to */
|
|
147
|
+
pendingAttentionCount?: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Claude-specific session metadata */
|
|
151
|
+
export interface ClaudeSessionMeta {
|
|
152
|
+
sessionId: string;
|
|
153
|
+
transcriptPath: string;
|
|
154
|
+
hookEventType?: string;
|
|
155
|
+
hookEventTime?: number;
|
|
156
|
+
hookSeq?: number;
|
|
157
|
+
permissionMode?: string;
|
|
158
|
+
startSource?: string;
|
|
159
|
+
endReason?: string;
|
|
160
|
+
errorType?: string;
|
|
161
|
+
attentionKind?: "permission" | "question" | "turn" | "none";
|
|
162
|
+
toolName?: string;
|
|
163
|
+
toolInputSummary?: string;
|
|
164
|
+
toolResultStatus?: "started" | "completed" | "error" | "blocked" | "denied" | "unknown";
|
|
165
|
+
agentId?: string;
|
|
166
|
+
agentType?: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Codex-specific session metadata */
|
|
170
|
+
export interface CodexSessionMeta {
|
|
171
|
+
sessionId: string;
|
|
172
|
+
transcriptPath: string;
|
|
173
|
+
hookEventType?: string;
|
|
174
|
+
hookEventTime?: number;
|
|
175
|
+
hookSeq?: number;
|
|
176
|
+
permissionMode?: string;
|
|
177
|
+
model?: string;
|
|
178
|
+
startSource?: string;
|
|
179
|
+
endReason?: string;
|
|
180
|
+
attentionKind?: "permission" | "question" | "turn" | "none";
|
|
181
|
+
toolName?: string;
|
|
182
|
+
toolInputSummary?: string;
|
|
183
|
+
toolResultStatus?: "started" | "completed" | "error" | "blocked" | "denied" | "unknown";
|
|
184
|
+
turnId?: string;
|
|
185
|
+
agentId?: string;
|
|
186
|
+
agentType?: string;
|
|
187
|
+
agentNickname?: string;
|
|
188
|
+
agentRole?: string;
|
|
189
|
+
parentSessionId?: string;
|
|
190
|
+
forkedFromId?: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Unified session model used throughout daemon, API, MCP, and TUI */
|
|
194
|
+
export interface SessionModel {
|
|
195
|
+
id: string;
|
|
196
|
+
source: SessionSource;
|
|
197
|
+
tool: SessionTool;
|
|
198
|
+
state: SessionState;
|
|
199
|
+
project?: ProjectInfo;
|
|
200
|
+
tmux?: {
|
|
201
|
+
session: string;
|
|
202
|
+
window: string;
|
|
203
|
+
pane: string;
|
|
204
|
+
};
|
|
205
|
+
summary: string;
|
|
206
|
+
lastActivity: number;
|
|
207
|
+
stateChangedAt?: number;
|
|
208
|
+
activeTool?: string;
|
|
209
|
+
/** Populated when detected via OpenCode SDK */
|
|
210
|
+
opencode?: OpenCodeSessionMeta;
|
|
211
|
+
/** Populated when detected via Claude JSONL transcript */
|
|
212
|
+
claude?: ClaudeSessionMeta;
|
|
213
|
+
/** Populated when detected via Codex JSONL transcript or hooks */
|
|
214
|
+
codex?: CodexSessionMeta;
|
|
215
|
+
/** Populated when detected via legacy heuristics */
|
|
216
|
+
legacyPaneId?: string;
|
|
217
|
+
/** Data source description for UI display */
|
|
218
|
+
detectionSource?: "opencode-sdk" | "opencode-hook" | "claude-transcript" | "claude-hook" | "codex-transcript" | "codex-hook" | "fallback-detection" | "opencode-db";
|
|
219
|
+
/** ID of the parent session when this is a sub-agent session spawned by another session */
|
|
220
|
+
parentSessionId?: string;
|
|
221
|
+
/** IDs of direct child sessions spawned by this session (populated on parent sessions) */
|
|
222
|
+
childSessionIds?: string[];
|
|
223
|
+
/** Timestamp when the session first entered "working" state in the current work stretch.
|
|
224
|
+
* Preserved across brief idle→working transitions (grace period). */
|
|
225
|
+
lastWorkingChangedAt?: number;
|
|
226
|
+
/** Timestamp when the last child session transitioned away from "working" or "waiting-input".
|
|
227
|
+
* Used to apply a grace period for parent sessions that briefly go idle between sub-agent turns. */
|
|
228
|
+
lastChildActiveAt?: number;
|
|
229
|
+
|
|
230
|
+
// ── Extended fields (populated by tmux collector / enrichment) ────────────
|
|
231
|
+
/** Full process tree rooted at the pane PID */
|
|
232
|
+
processTree?: ProcessInfo[];
|
|
233
|
+
/** Compact process chain (comm names) */
|
|
234
|
+
processChain?: string[];
|
|
235
|
+
/** Captured pane output sample */
|
|
236
|
+
sample?: PaneSample | null;
|
|
237
|
+
/** Reasons for tool detection classification */
|
|
238
|
+
toolReasons?: DetectionReason[];
|
|
239
|
+
/** Reasons for state detection classification */
|
|
240
|
+
stateReasons?: DetectionReason[];
|
|
241
|
+
/** Milliseconds the session has been in the current state */
|
|
242
|
+
stableForMs?: number;
|
|
243
|
+
/** Whether the session needs user attention */
|
|
244
|
+
hasAttention?: boolean;
|
|
245
|
+
/** Current command running in the pane */
|
|
246
|
+
currentCommand?: string;
|
|
247
|
+
/** Current working directory of the pane */
|
|
248
|
+
currentPath?: string;
|
|
249
|
+
/** PID of the tmux pane shell process */
|
|
250
|
+
panePid?: number;
|
|
251
|
+
/** TTY device path */
|
|
252
|
+
tty?: string;
|
|
253
|
+
/** Pane title set by the running program */
|
|
254
|
+
paneTitle?: string;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Minimal session summary used in the API tree response (children arrays).
|
|
259
|
+
* Contains only the fields needed to understand a sub-session at a glance.
|
|
260
|
+
*/
|
|
261
|
+
export interface SessionSummary {
|
|
262
|
+
id: string;
|
|
263
|
+
tool: SessionTool;
|
|
264
|
+
state: SessionState;
|
|
265
|
+
summary: string;
|
|
266
|
+
lastActivity: number;
|
|
267
|
+
parentSessionId?: string;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── Daemon state ──────────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
export interface DaemonState {
|
|
273
|
+
tmuxSessions: SessionInfo[];
|
|
274
|
+
sessions: SessionModel[];
|
|
275
|
+
collectorStatus: Record<string, CollectorStatus>;
|
|
276
|
+
usage: import("../usage/types").UsageState;
|
|
277
|
+
startedAt: number;
|
|
278
|
+
refreshedAt: number;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface CollectorStatus {
|
|
282
|
+
name: string;
|
|
283
|
+
running: boolean;
|
|
284
|
+
lastRunAt: number;
|
|
285
|
+
lastDurationMs?: number;
|
|
286
|
+
skippedPolls?: number;
|
|
287
|
+
errorCount?: number;
|
|
288
|
+
lastError?: string;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Store events ──────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
export type StoreEvent =
|
|
294
|
+
| { type: "session:added"; session: SessionModel }
|
|
295
|
+
| { type: "session:updated"; session: SessionModel }
|
|
296
|
+
| { type: "session:removed"; sessionId: string }
|
|
297
|
+
| { type: "state:refreshed"; timestamp: number }
|
|
298
|
+
| { type: "error"; source: string; message: string }
|
|
299
|
+
| { type: "usage:updated"; usage: import("../usage/types").UsageState };
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
export interface CommandOptions {
|
|
4
|
+
cwd?: string;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function runCommand(command: string[], timeoutMs?: number): string {
|
|
9
|
+
return runCommandInDirectory(command, { timeoutMs });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function runCommandInDirectory(command: string[], options: CommandOptions = {}): string {
|
|
13
|
+
const proc = Bun.spawnSync(command, {
|
|
14
|
+
cwd: options.cwd,
|
|
15
|
+
stdout: "pipe",
|
|
16
|
+
stderr: "pipe",
|
|
17
|
+
...(options.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (proc.exitCode !== 0) {
|
|
21
|
+
const error = Buffer.from(proc.stderr).toString("utf8").trim() || `Command failed: ${command.join(" ")}`;
|
|
22
|
+
throw new Error(error);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return Buffer.from(proc.stdout).toString("utf8");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function maybeRunCommand(command: string[], timeoutMs?: number): { ok: true; output: string } | { ok: false; error: string } {
|
|
29
|
+
return maybeRunCommandInDirectory(command, { timeoutMs });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function maybeRunCommandInDirectory(command: string[], options: CommandOptions = {}): { ok: true; output: string } | { ok: false; error: string } {
|
|
33
|
+
try {
|
|
34
|
+
return { ok: true, output: runCommandInDirectory(command, options) };
|
|
35
|
+
} catch (error) {
|
|
36
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function parseIntSafe(value: string, fallback = 0): number {
|
|
41
|
+
const parsed = Number.parseInt(value, 10);
|
|
42
|
+
return Number.isNaN(parsed) ? fallback : parsed;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function safeBasename(pathname: string): string {
|
|
46
|
+
if (!pathname) {
|
|
47
|
+
return "-";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const base = basename(pathname);
|
|
51
|
+
return base || pathname;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function truncate(value: string, length: number): string {
|
|
55
|
+
if (value.length <= length) {
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (length <= 1) {
|
|
60
|
+
return value.slice(0, length);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return `${value.slice(0, length - 1)}...`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function compactWhitespace(value: string): string {
|
|
67
|
+
return value.replace(/\s+/g, " ").trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function cleanSummaryText(value: string): string {
|
|
71
|
+
return compactWhitespace(stripAnsiAndUi(value))
|
|
72
|
+
.replace(/^[•\-\*]+\s*/, "")
|
|
73
|
+
.replace(/^>\s+/, "")
|
|
74
|
+
.trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function stripAnsiAndUi(value: string): string {
|
|
78
|
+
return value
|
|
79
|
+
.replace(/\u001b\[[0-9;]*m/g, "")
|
|
80
|
+
.replace(/[│┃╭╮╰╯╹▀─]+/g, " ")
|
|
81
|
+
.replace(/[›]/g, ">")
|
|
82
|
+
.trim();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function isMostlyUiNoise(value: string): boolean {
|
|
86
|
+
const cleaned = cleanSummaryText(value);
|
|
87
|
+
if (!cleaned) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return /(ctrl\+t variants|tab agents|ctrl\+p commands|100% context left|Tip: Use \/feedback|\? for shortcuts|↑↓ select|enter submit|esc dismiss)/i.test(cleaned)
|
|
92
|
+
|| isOpencodeFooterLine(cleaned);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function isLowValueSummaryLine(value: string): boolean {
|
|
96
|
+
const cleaned = cleanSummaryText(value);
|
|
97
|
+
if (!cleaned) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
isMostlyUiNoise(cleaned)
|
|
103
|
+
|| /^>\s*implement\s*\{feature\}/i.test(cleaned)
|
|
104
|
+
|| /^\d+\.\s/.test(cleaned)
|
|
105
|
+
|| /^[.·•\u2800-\u28ff\s]{4,}$/u.test(cleaned)
|
|
106
|
+
|| /^\$\s/.test(cleaned)
|
|
107
|
+
|| /^▣\s+(build|plan)\b/i.test(cleaned)
|
|
108
|
+
|| /^(model:|directory:)/i.test(cleaned)
|
|
109
|
+
|| /^(working\s*\(|build\b|plan\b|asked\s+\d+\s+question)/i.test(cleaned)
|
|
110
|
+
|| /^gpt-[\w.-]+.*% left/i.test(cleaned)
|
|
111
|
+
|| /^(build|plan)\s+.*(github copilot|openai)$/i.test(cleaned)
|
|
112
|
+
|| /^(yes, continue|no, quit|press enter to continue)$/i.test(cleaned)
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function isSuggestionStyleText(value: string): boolean {
|
|
117
|
+
const cleaned = cleanSummaryText(value);
|
|
118
|
+
if (!cleaned) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
/^(next steps?|siguientes? pasos?|siguiente paso natural|recommended next step|next step natural|natural next step)/i.test(cleaned)
|
|
124
|
+
|| /recommended by default|recomendado por defecto/i.test(cleaned)
|
|
125
|
+
|| /^(si quieres|if you want|you can|puedes)\b/i.test(cleaned)
|
|
126
|
+
|| /^\d+\.\s/.test(cleaned)
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function isOpencodeFooterLine(value: string): boolean {
|
|
131
|
+
const cleaned = cleanSummaryText(value);
|
|
132
|
+
if (!cleaned) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return /(esc|escape)(?:\s+to)?\s+(interrupt|cancel|stop)\b|(interrupt|cancel|stop)\b.*\b(esc|escape)\b/i.test(cleaned);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function pickMeaningfulTail(lines: string[], fallback = ""): string {
|
|
140
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
141
|
+
const cleaned = cleanSummaryText(lines[index] ?? "");
|
|
142
|
+
if (!cleaned) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (isMostlyUiNoise(cleaned)) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
return cleaned;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return fallback;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function pickSummaryTail(lines: string[], fallback = ""): string {
|
|
155
|
+
const normalized = lines.map((line) => cleanSummaryText(line));
|
|
156
|
+
|
|
157
|
+
const preferred = pickPreferredSummaryLine(normalized);
|
|
158
|
+
if (preferred) {
|
|
159
|
+
return preferred;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (let index = normalized.length - 1; index >= 0; index -= 1) {
|
|
163
|
+
const current = normalized[index] ?? "";
|
|
164
|
+
if (!current || isLowValueSummaryLine(current)) {
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (isOptionDescription(normalized, index)) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const block: string[] = [current];
|
|
172
|
+
for (let lookback = index - 1; lookback >= 0; lookback -= 1) {
|
|
173
|
+
const previous = normalized[lookback] ?? "";
|
|
174
|
+
if (!previous || isLowValueSummaryLine(previous) || isOptionDescription(normalized, lookback)) {
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
if (/^[•\-]/.test(previous) || /^[•\-]/.test(current) || previous.endsWith(":") || current.length < 100) {
|
|
178
|
+
block.unshift(previous);
|
|
179
|
+
if (block.length >= 2) {
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return cleanSummaryText(block.join(" "));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return pickMeaningfulTail(lines, fallback);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function pickQuestionTail(lines: string[], fallback = ""): string {
|
|
194
|
+
const normalized = lines.map((line) => cleanSummaryText(line));
|
|
195
|
+
|
|
196
|
+
for (let index = normalized.length - 1; index >= 0; index -= 1) {
|
|
197
|
+
const line = normalized[index] ?? "";
|
|
198
|
+
if (!line || isLowValueSummaryLine(line) || isOptionDescription(normalized, index)) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (isSuggestionStyleText(line)) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (/[¿?]/.test(line) || /^(confirmas|what|which|que|qué|cual|cuál|do you|can you|would you|elige|escoge|selecciona|resp[oó]ndeme)/i.test(line)) {
|
|
207
|
+
const previous = normalized[index - 1] ?? "";
|
|
208
|
+
if (/[¿?]/.test(line)) {
|
|
209
|
+
if (previous && !isLowValueSummaryLine(previous) && !isOptionDescription(normalized, index - 1) && previous.length < 90 && /^[a-záéíóúñ]/i.test(line)) {
|
|
210
|
+
return cleanSummaryText(`${previous} ${line}`);
|
|
211
|
+
}
|
|
212
|
+
return line;
|
|
213
|
+
}
|
|
214
|
+
if (previous && !isLowValueSummaryLine(previous) && !isOptionDescription(normalized, index - 1) && !/[¿?]/.test(previous) && previous.length < 90) {
|
|
215
|
+
return cleanSummaryText(`${previous} ${line}`);
|
|
216
|
+
}
|
|
217
|
+
return line;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return pickSummaryTail(lines, fallback);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function pickCompletionTail(lines: string[], fallback = ""): string {
|
|
225
|
+
const normalized = lines.map((line) => cleanSummaryText(line));
|
|
226
|
+
|
|
227
|
+
for (let index = normalized.length - 1; index >= 0; index -= 1) {
|
|
228
|
+
const line = normalized[index] ?? "";
|
|
229
|
+
if (!line || isLowValueSummaryLine(line) || isOptionDescription(normalized, index)) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (/^(se cre[oó]|cada archivo contiene|all done|completed|finished|done\b)/i.test(line)) {
|
|
234
|
+
const previousIndex = findPreviousMeaningfulIndex(normalized, index - 1);
|
|
235
|
+
const previous = previousIndex >= 0 ? normalized[previousIndex] ?? "" : "";
|
|
236
|
+
if (previous && !isLowValueSummaryLine(previous) && !isOptionDescription(normalized, previousIndex) && /^(se cre[oó]|all done|completed|finished|done\b)/i.test(previous)) {
|
|
237
|
+
return firstUsefulSentence(cleanSummaryText(`${previous} ${line}`));
|
|
238
|
+
}
|
|
239
|
+
return firstUsefulSentence(line);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return pickSummaryTail(lines, fallback);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function pickPreferredSummaryLine(lines: string[]): string | null {
|
|
247
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
248
|
+
const line = lines[index] ?? "";
|
|
249
|
+
if (!line || isLowValueSummaryLine(line) || isOptionDescription(lines, index)) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (isSuggestionStyleText(line)) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (/^[•\-]\s/.test(line) && !/waited for background terminal|working\s*\(/i.test(line)) {
|
|
258
|
+
return cleanSummaryText(line);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (/[¿?]/.test(line) || /^(resp[oó]ndeme|creado archivo \d+|va en orden|carpeta creada|listo\.|voy a |el proceso ya|el lote|s[ií]:|se cre[oó]|cada archivo contiene)/i.test(line)) {
|
|
262
|
+
const previous = lines[index - 1] ?? "";
|
|
263
|
+
if (previous && !isLowValueSummaryLine(previous) && !isOptionDescription(lines, index - 1) && previous.length < 120 && line.length < 120 && !/^(creado archivo \d+)/i.test(line)) {
|
|
264
|
+
return firstUsefulSentence(cleanSummaryText(`${previous} ${line}`));
|
|
265
|
+
}
|
|
266
|
+
return firstUsefulSentence(cleanSummaryText(line));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isOptionDescription(lines: string[], index: number): boolean {
|
|
274
|
+
const current = lines[index] ?? "";
|
|
275
|
+
if (!current) {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const previous = lines[index - 1] ?? "";
|
|
280
|
+
return /^\d+\.\s/.test(previous) || /^(una sola|una respuesta|varias opciones)/i.test(current);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function findPreviousMeaningfulIndex(lines: string[], startIndex: number): number {
|
|
284
|
+
for (let index = startIndex; index >= 0; index -= 1) {
|
|
285
|
+
if (lines[index]?.trim()) {
|
|
286
|
+
return index;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return -1;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function firstUsefulSentence(value: string): string {
|
|
294
|
+
const normalized = cleanSummaryText(value);
|
|
295
|
+
const sentences = normalized.match(/.*?[.!?](?:\s|$)|.+$/g)?.map((part) => cleanSummaryText(part)).filter(Boolean) ?? [];
|
|
296
|
+
if (sentences.length === 0) {
|
|
297
|
+
return normalized;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const first = sentences[0] ?? normalized;
|
|
301
|
+
if (/^(listo|hecho)[.!?]?$/i.test(first) && sentences[1]) {
|
|
302
|
+
return sentences[1];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return first;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function formatDuration(ms: number): string {
|
|
309
|
+
if (ms < 1000) {
|
|
310
|
+
return "0s";
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
314
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
315
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
316
|
+
const seconds = totalSeconds % 60;
|
|
317
|
+
|
|
318
|
+
if (hours > 0) {
|
|
319
|
+
return `${hours}h${minutes}m`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (minutes > 0) {
|
|
323
|
+
return `${minutes}m${seconds}s`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return `${seconds}s`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function joinLines(lines: string[]): string {
|
|
330
|
+
return compactWhitespace(lines.join(" "));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function normalizeCommandName(command: string): string {
|
|
334
|
+
return compactWhitespace(command).split(" ")[0] ?? "";
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function isInsideTmux(): boolean {
|
|
338
|
+
return Boolean(process.env.TMUX);
|
|
339
|
+
}
|