@scrylog/cli 0.1.4 → 0.3.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/README.md +46 -7
- package/package.json +13 -10
- package/src/api/docs/sessions.ts +3 -2
- package/src/api/docs/system.ts +3 -0
- package/src/api/docs/usage.ts +2 -2
- package/src/api/routes/sessions.ts +6 -2
- package/src/api/routes/system.ts +4 -1
- package/src/api/routes/webhooks/claude.ts +9 -21
- package/src/api/routes/webhooks/opencode.ts +6 -1
- package/src/api/routes.ts +1 -1
- package/src/api/schemas/common.ts +8 -0
- package/src/api/schemas/sessions.ts +15 -1
- package/src/api/schemas/usage.ts +1 -0
- package/src/cli/commands.ts +170 -26
- package/src/cli/index.ts +18 -0
- package/src/cli/program.ts +175 -0
- package/src/daemon/collectors/claude/collector.ts +11 -1
- package/src/daemon/collectors/claude/discovery.ts +2 -23
- package/src/daemon/collectors/codex/collector.ts +3 -3
- package/src/daemon/collectors/git.ts +60 -2
- package/src/daemon/collectors/herdr/collector.ts +128 -0
- package/src/daemon/collectors/herdr/index.ts +2 -0
- package/src/daemon/collectors/herdr/normalization.ts +111 -0
- package/src/daemon/collectors/opencode/collector.ts +4 -2
- package/src/daemon/collectors/opencode-db.ts +17 -5
- package/src/daemon/collectors/tmux/collector.ts +21 -1
- package/src/daemon/collectors/tmux/enrichment.ts +1 -0
- package/src/daemon/collectors/usage.ts +115 -49
- package/src/daemon/index.ts +2 -0
- package/src/daemon/store.ts +109 -14
- package/src/postinstall.cjs +11 -0
- package/src/shared/config.ts +33 -0
- package/src/shared/session-exclusions.ts +5 -0
- package/src/shared/session-view.ts +67 -6
- package/src/shared/terminal-capabilities.ts +130 -0
- package/src/shared/types.ts +33 -2
- package/src/shared/version.ts +44 -0
- package/src/tui/App.tsx +240 -0
- package/src/tui/assets/providers/SOURCES.md +21 -0
- package/src/tui/assets/providers/claude-terminal.png +0 -0
- package/src/tui/assets/providers/claude-terminal.svg +62 -0
- package/src/tui/assets/providers/claude.png +0 -0
- package/src/tui/assets/providers/copilot.png +0 -0
- package/src/tui/assets/providers/copilot.svg +44 -0
- package/src/tui/assets/providers/openai-terminal.png +0 -0
- package/src/tui/assets/providers/openai-terminal.svg +62 -0
- package/src/tui/assets/providers/openai.png +0 -0
- package/src/tui/assets/providers/opencode-terminal.png +0 -0
- package/src/tui/assets/providers/opencode-terminal.svg +62 -0
- package/src/tui/assets/providers/opencode.png +0 -0
- package/src/tui/assets/providers/shell.png +0 -0
- package/src/tui/assets/providers/shell.svg +23 -0
- package/src/tui/assets/providers/unknown.png +0 -0
- package/src/tui/assets/providers/unknown.svg +23 -0
- package/src/tui/client.ts +22 -8
- package/src/tui/components/BindingsModal.tsx +82 -0
- package/src/tui/components/DashboardHeader.tsx +256 -0
- package/src/tui/components/KeyBar.tsx +67 -0
- package/src/tui/components/SessionDetailModal.tsx +169 -0
- package/src/tui/components/SessionsView.tsx +184 -0
- package/src/tui/components/TabBar.tsx +40 -0
- package/src/tui/components/UsageSummaryPane.tsx +272 -0
- package/src/tui/components/UsageView.tsx +242 -0
- package/src/tui/components/tool-icons.ts +24 -0
- package/src/tui/index.tsx +106 -0
- package/src/tui/keybindings.ts +49 -0
- package/src/tui/model.ts +295 -0
- package/src/tui/state.ts +77 -0
- package/src/tui/theme.ts +110 -0
- package/src/usage/README.md +3 -3
- package/src/usage/clients/anthropic-oauth-usage.ts +100 -20
- package/src/usage/clients/anthropic-web-usage.ts +133 -49
- package/src/usage/clients/claude-cli-probe.ts +458 -132
- package/src/usage/clients/codex-app-server.ts +354 -0
- package/src/usage/clients/codex-oauth-usage.ts +143 -16
- package/src/usage/clients/opencode-go-api.ts +100 -0
- package/src/usage/clients/opencode-go-usage.ts +163 -27
- package/src/usage/errors.ts +33 -0
- package/src/usage/index.ts +2 -1
- package/src/usage/local/opencode-go-local.ts +194 -0
- package/src/usage/mappers.ts +24 -15
- package/src/usage/planner.ts +89 -25
- package/src/usage/providers/claude.ts +203 -83
- package/src/usage/providers/codex.ts +92 -11
- package/src/usage/providers/opencode-go.ts +94 -16
- package/src/usage/resolvers/browser-cookies.ts +679 -69
- package/src/usage/resolvers/claude-auth.ts +150 -41
- package/src/usage/resolvers/claude-desktop-auth.ts +170 -0
- package/src/usage/resolvers/claude-web.ts +72 -6
- package/src/usage/resolvers/codex-auth.ts +74 -17
- package/src/usage/resolvers/keychain-coordinator.ts +397 -0
- package/src/usage/resolvers/opencode-go-web.ts +47 -6
- package/src/usage/types.ts +37 -2
- package/src/usage/web-session-cache.ts +37 -0
- package/src/neo-blessed.d.ts +0 -4
- package/src/tui/app.ts +0 -920
- package/src/tui/index.ts +0 -193
package/src/daemon/store.ts
CHANGED
|
@@ -4,6 +4,22 @@ import { NOOP_LOGGER } from "../shared/logger";
|
|
|
4
4
|
import type { CollectorStatus, DaemonState, SessionInfo, SessionModel, StoreEvent } from "../shared/types";
|
|
5
5
|
import type { RetentionConfig } from "../shared/config";
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Returns true when an incoming claude-hook "working" event represents an explicit
|
|
9
|
+
* user response to a pending attention (permission or question), so the attention
|
|
10
|
+
* can be cleared. Background working events (unrelated subagent activity) return false.
|
|
11
|
+
*/
|
|
12
|
+
function resolvesAttention(existing: SessionModel, incoming: SessionModel): boolean {
|
|
13
|
+
const evt = incoming.claude?.hookEventType;
|
|
14
|
+
if (evt === "UserPromptSubmit" || evt === "PermissionDenied") return true;
|
|
15
|
+
if (
|
|
16
|
+
(evt === "PreToolUse" || evt === "PostToolUse")
|
|
17
|
+
&& existing.claude?.attentionKind === "permission"
|
|
18
|
+
&& incoming.claude?.toolName === existing.claude.toolName
|
|
19
|
+
) return true;
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
7
23
|
/**
|
|
8
24
|
* In-memory state store for the daemon.
|
|
9
25
|
* Maintains all session data and emits typed StoreEvent events on changes.
|
|
@@ -108,6 +124,24 @@ export class DaemonStore extends EventEmitter {
|
|
|
108
124
|
// enrichment fields. This prevents the opencode SDK/DB/cluade collectors from
|
|
109
125
|
// wiping out tmux enrichment on every poll/SSE cycle.
|
|
110
126
|
let session = incoming;
|
|
127
|
+
if (
|
|
128
|
+
existing?.source === "herdr"
|
|
129
|
+
&& existing.herdr
|
|
130
|
+
&& incoming.detectionSource !== "herdr"
|
|
131
|
+
&& incoming.source !== "tmux"
|
|
132
|
+
&& !incoming.tmux
|
|
133
|
+
&& !incoming.legacyPaneId
|
|
134
|
+
) {
|
|
135
|
+
session = {
|
|
136
|
+
...incoming,
|
|
137
|
+
source: "herdr",
|
|
138
|
+
herdr: existing.herdr,
|
|
139
|
+
project: incoming.project ?? existing.project,
|
|
140
|
+
currentPath: incoming.currentPath ?? existing.currentPath,
|
|
141
|
+
currentCommand: incoming.currentCommand ?? existing.currentCommand,
|
|
142
|
+
paneTitle: incoming.paneTitle ?? existing.paneTitle,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
111
145
|
if (existing?.legacyPaneId && !incoming.legacyPaneId) {
|
|
112
146
|
const stableForMs = existing.state !== incoming.state ? 0 : existing.stableForMs;
|
|
113
147
|
session = {
|
|
@@ -143,6 +177,18 @@ export class DaemonStore extends EventEmitter {
|
|
|
143
177
|
});
|
|
144
178
|
}
|
|
145
179
|
|
|
180
|
+
// For all sessions: preserve a previously-resolved project/currentPath when
|
|
181
|
+
// the incoming model lacks them. Project resolution can transiently fail
|
|
182
|
+
// (Claude slug decode returning null, OpenCode directory not yet populated),
|
|
183
|
+
// and once resolved the association must not oscillate back to undefined.
|
|
184
|
+
// An incoming model that carries a non-empty project still wins.
|
|
185
|
+
if (existing && !session.project && existing.project) {
|
|
186
|
+
session = { ...session, project: existing.project };
|
|
187
|
+
}
|
|
188
|
+
if (existing && !session.currentPath && existing.currentPath) {
|
|
189
|
+
session = { ...session, currentPath: existing.currentPath };
|
|
190
|
+
}
|
|
191
|
+
|
|
146
192
|
// Preserve stateChangedAt from the stored session when the incoming model
|
|
147
193
|
// doesn't carry it (e.g. built by processTranscript from file state only).
|
|
148
194
|
if (existing && !session.stateChangedAt && existing.stateChangedAt) {
|
|
@@ -163,25 +209,50 @@ export class DaemonStore extends EventEmitter {
|
|
|
163
209
|
session = { ...session, state: existing.state, stateChangedAt: existing.stateChangedAt };
|
|
164
210
|
} else {
|
|
165
211
|
this.suppressedIdle.delete(session.id);
|
|
166
|
-
|
|
212
|
+
|
|
213
|
+
// For hook-managed Claude sessions: pending attention (waiting-input +
|
|
214
|
+
// hasAttention) takes precedence over concurrent background working events
|
|
215
|
+
// until the user explicitly responds. Background subagent activity must not
|
|
216
|
+
// clear the permission/question from the dashboard.
|
|
217
|
+
const pendingAttention = existing.state === "waiting-input"
|
|
218
|
+
&& existing.hasAttention === true
|
|
167
219
|
&& session.state === "working"
|
|
168
|
-
&&
|
|
169
|
-
&& (
|
|
220
|
+
&& session.detectionSource === "claude-hook"
|
|
221
|
+
&& !resolvesAttention(existing, session);
|
|
170
222
|
|
|
171
|
-
if (
|
|
223
|
+
if (pendingAttention) {
|
|
224
|
+
// Revert to waiting-input, advancing only the claude metadata (hookSeq etc.)
|
|
225
|
+
// so subsequent events are not rejected as stale.
|
|
172
226
|
session = {
|
|
173
227
|
...session,
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
228
|
+
state: "waiting-input" as SessionModel["state"],
|
|
229
|
+
hasAttention: true,
|
|
230
|
+
summary: existing.summary,
|
|
231
|
+
activeTool: existing.activeTool,
|
|
232
|
+
stateChangedAt: existing.stateChangedAt,
|
|
233
|
+
stableForMs: existing.stableForMs,
|
|
179
234
|
};
|
|
180
235
|
} else {
|
|
181
|
-
|
|
236
|
+
const isGracePeriodIdleToWorking = existing.state === "idle"
|
|
237
|
+
&& session.state === "working"
|
|
238
|
+
&& existing.lastChildActiveAt
|
|
239
|
+
&& (now - existing.lastChildActiveAt) < 1500;
|
|
240
|
+
|
|
241
|
+
if (session.state === "working") {
|
|
242
|
+
session = {
|
|
243
|
+
...session,
|
|
244
|
+
stableForMs: 0,
|
|
245
|
+
stateChangedAt: now,
|
|
246
|
+
lastWorkingChangedAt: isGracePeriodIdleToWorking
|
|
247
|
+
? (existing.lastWorkingChangedAt ?? now)
|
|
248
|
+
: now,
|
|
249
|
+
};
|
|
250
|
+
} else {
|
|
251
|
+
session = { ...session, stableForMs: 0, stateChangedAt: now };
|
|
182
252
|
|
|
183
|
-
|
|
184
|
-
|
|
253
|
+
if (session.state === "waiting-input" || session.state === "error") {
|
|
254
|
+
session = { ...session, lastWorkingChangedAt: undefined, lastChildActiveAt: undefined };
|
|
255
|
+
}
|
|
185
256
|
}
|
|
186
257
|
}
|
|
187
258
|
}
|
|
@@ -366,6 +437,29 @@ export class DaemonStore extends EventEmitter {
|
|
|
366
437
|
};
|
|
367
438
|
}
|
|
368
439
|
|
|
440
|
+
/** Remove herdr location while retaining native collector session data. */
|
|
441
|
+
clearHerdrBinding(sessionId: string): void {
|
|
442
|
+
const existing = this.state.sessions.find((session) => session.id === sessionId);
|
|
443
|
+
if (!existing || existing.source !== "herdr" || !existing.herdr || existing.detectionSource === "herdr") {
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const session: SessionModel = {
|
|
448
|
+
...existing,
|
|
449
|
+
source: "standalone",
|
|
450
|
+
herdr: undefined,
|
|
451
|
+
currentCommand: undefined,
|
|
452
|
+
paneTitle: undefined,
|
|
453
|
+
};
|
|
454
|
+
this.state = {
|
|
455
|
+
...this.state,
|
|
456
|
+
sessions: this.state.sessions.map((item) => item.id === sessionId ? session : item),
|
|
457
|
+
refreshedAt: Date.now(),
|
|
458
|
+
};
|
|
459
|
+
this.emit("store:event", { type: "session:updated", session } satisfies StoreEvent);
|
|
460
|
+
this.emit("store:event", { type: "state:refreshed", timestamp: this.state.refreshedAt } satisfies StoreEvent);
|
|
461
|
+
}
|
|
462
|
+
|
|
369
463
|
/** Update usage state and emit event */
|
|
370
464
|
setUsage(usage: import("../usage/types").UsageState): void {
|
|
371
465
|
this.state = { ...this.state, usage };
|
|
@@ -465,9 +559,10 @@ export class DaemonStore extends EventEmitter {
|
|
|
465
559
|
const maxSessions = retention.maxSessions;
|
|
466
560
|
if (maxSessions > 0) {
|
|
467
561
|
const survivors = this.state.sessions.filter((session) => !removeIds.has(session.id));
|
|
468
|
-
const
|
|
562
|
+
const counted = survivors.filter((session) => session.source === "standalone" && session.tool !== "unknown");
|
|
563
|
+
const overflow = counted.length - maxSessions;
|
|
469
564
|
if (overflow > 0) {
|
|
470
|
-
const candidates =
|
|
565
|
+
const candidates = counted
|
|
471
566
|
.filter((session) => !active(session))
|
|
472
567
|
.sort((a, b) => a.lastActivity - b.lastActivity);
|
|
473
568
|
for (const session of candidates.slice(0, overflow)) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const message = [
|
|
2
|
+
"scrylog installed or updated.",
|
|
3
|
+
"If scrylogd is already running, activate this version with:",
|
|
4
|
+
" scrylog daemon restart",
|
|
5
|
+
"For a foreground or unsupported service, stop it and run:",
|
|
6
|
+
" scrylogd",
|
|
7
|
+
"Hooks use the installed binaries and do not need reinstallation.",
|
|
8
|
+
"If integrations misbehave, run: scrylog setup",
|
|
9
|
+
].join("\n");
|
|
10
|
+
|
|
11
|
+
process.stdout.write(`${message}\n`);
|
package/src/shared/config.ts
CHANGED
|
@@ -91,8 +91,20 @@ export interface TuiConfig {
|
|
|
91
91
|
standaloneMaxAgeHours: number;
|
|
92
92
|
/** Debounce window in ms for coalescing push-event bursts into a single refresh (default: 120, 0 = disabled) */
|
|
93
93
|
pushDebounceMs: number;
|
|
94
|
+
/**
|
|
95
|
+
* Provider icon rendering mode (default: blocks):
|
|
96
|
+
* - "blocks": terminal pixel art, works everywhere including tmux
|
|
97
|
+
* - "graphics": full artwork negotiated automatically (kitty → sixel → blocks)
|
|
98
|
+
* - "kitty" / "sixel": force a specific native graphics protocol, ignoring detection
|
|
99
|
+
* - "text": colored provider initials
|
|
100
|
+
*/
|
|
101
|
+
providerIcons: "blocks" | "graphics" | "kitty" | "sixel" | "text";
|
|
94
102
|
}
|
|
95
103
|
|
|
104
|
+
type ProviderIconsMode = TuiConfig["providerIcons"];
|
|
105
|
+
|
|
106
|
+
const PROVIDER_ICON_MODES: readonly ProviderIconsMode[] = ["blocks", "graphics", "kitty", "sixel", "text"];
|
|
107
|
+
|
|
96
108
|
export interface McpConfig {
|
|
97
109
|
/** Whether the local stdio MCP binary should be allowed (default: true) */
|
|
98
110
|
stdioEnabled: boolean;
|
|
@@ -142,6 +154,14 @@ export interface UsageOpenCodeGoConfig {
|
|
|
142
154
|
serverFnId: string;
|
|
143
155
|
}
|
|
144
156
|
|
|
157
|
+
export interface UsageAuthConfig {
|
|
158
|
+
/**
|
|
159
|
+
* Whether background usage polls may read foreign Keychain secrets (macOS).
|
|
160
|
+
* `false` keeps the daemon prompt-free but disables Keychain-backed credential sources.
|
|
161
|
+
*/
|
|
162
|
+
allowKeychainPrompts: boolean;
|
|
163
|
+
}
|
|
164
|
+
|
|
145
165
|
export interface UsageConfig {
|
|
146
166
|
/** Whether usage collection is enabled (default: true) */
|
|
147
167
|
enabled: boolean;
|
|
@@ -152,6 +172,7 @@ export interface UsageConfig {
|
|
|
152
172
|
claude: UsageClaudeConfig;
|
|
153
173
|
codex: UsageCodexConfig;
|
|
154
174
|
opencodeGo: UsageOpenCodeGoConfig;
|
|
175
|
+
auth: UsageAuthConfig;
|
|
155
176
|
}
|
|
156
177
|
|
|
157
178
|
export interface RetentionConfig {
|
|
@@ -233,6 +254,7 @@ export const CONFIG_DEFAULTS: ScrylogConfig = {
|
|
|
233
254
|
usePushUpdates: true,
|
|
234
255
|
standaloneMaxAgeHours: 6,
|
|
235
256
|
pushDebounceMs: 120,
|
|
257
|
+
providerIcons: "blocks",
|
|
236
258
|
},
|
|
237
259
|
mcp: {
|
|
238
260
|
stdioEnabled: true,
|
|
@@ -254,6 +276,9 @@ export const CONFIG_DEFAULTS: ScrylogConfig = {
|
|
|
254
276
|
opencodeGo: {
|
|
255
277
|
serverFnId: "def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f",
|
|
256
278
|
},
|
|
279
|
+
auth: {
|
|
280
|
+
allowKeychainPrompts: true,
|
|
281
|
+
},
|
|
257
282
|
},
|
|
258
283
|
retention: {
|
|
259
284
|
maxSessions: 500,
|
|
@@ -456,10 +481,18 @@ function normalizeOperationalConfig(config: ScrylogConfig): ScrylogConfig {
|
|
|
456
481
|
refreshMs: clampNumber(config.tui.refreshMs, 500, 300_000, CONFIG_DEFAULTS.tui.refreshMs),
|
|
457
482
|
standaloneMaxAgeHours: clampNumber(config.tui.standaloneMaxAgeHours, 0, 8_760, CONFIG_DEFAULTS.tui.standaloneMaxAgeHours),
|
|
458
483
|
pushDebounceMs: clampNumber(config.tui.pushDebounceMs, 0, 2_000, CONFIG_DEFAULTS.tui.pushDebounceMs),
|
|
484
|
+
providerIcons: PROVIDER_ICON_MODES.includes(config.tui.providerIcons)
|
|
485
|
+
? config.tui.providerIcons
|
|
486
|
+
: CONFIG_DEFAULTS.tui.providerIcons,
|
|
459
487
|
},
|
|
460
488
|
usage: {
|
|
461
489
|
...config.usage,
|
|
462
490
|
pollIntervalMs: clampNumber(config.usage.pollIntervalMs, 60_000, 3_600_000, CONFIG_DEFAULTS.usage.pollIntervalMs),
|
|
491
|
+
auth: {
|
|
492
|
+
allowKeychainPrompts: typeof config.usage.auth?.allowKeychainPrompts === "boolean"
|
|
493
|
+
? config.usage.auth.allowKeychainPrompts
|
|
494
|
+
: CONFIG_DEFAULTS.usage.auth.allowKeychainPrompts,
|
|
495
|
+
},
|
|
463
496
|
},
|
|
464
497
|
retention: {
|
|
465
498
|
...config.retention,
|
|
@@ -15,6 +15,18 @@ export interface SessionListTmuxInfo {
|
|
|
15
15
|
session: string;
|
|
16
16
|
window: string;
|
|
17
17
|
pane: string;
|
|
18
|
+
windowName?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SessionListHerdrInfo {
|
|
22
|
+
paneId: string;
|
|
23
|
+
terminalId?: string;
|
|
24
|
+
workspaceId?: string;
|
|
25
|
+
workspace?: string;
|
|
26
|
+
tabId?: string;
|
|
27
|
+
tab?: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
agent?: string;
|
|
18
30
|
}
|
|
19
31
|
|
|
20
32
|
export interface SessionListItem {
|
|
@@ -28,8 +40,11 @@ export interface SessionListItem {
|
|
|
28
40
|
hasAttention?: boolean;
|
|
29
41
|
detectionSource?: SessionModel["detectionSource"];
|
|
30
42
|
parentSessionId?: string;
|
|
43
|
+
/** Human label for the session row: pane title > window/tab name > tmux session name */
|
|
44
|
+
displayName?: string;
|
|
31
45
|
project?: SessionListProjectInfo;
|
|
32
46
|
tmux?: SessionListTmuxInfo;
|
|
47
|
+
herdr?: SessionListHerdrInfo;
|
|
33
48
|
currentPath?: string;
|
|
34
49
|
activeTool?: string;
|
|
35
50
|
childCount?: number;
|
|
@@ -54,6 +69,10 @@ export interface SessionView {
|
|
|
54
69
|
sessions: SessionTreeItem[];
|
|
55
70
|
}
|
|
56
71
|
|
|
72
|
+
export interface SessionViewOptions {
|
|
73
|
+
includeUnknown?: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
function makeStateCounts(): Record<SessionState, number> {
|
|
58
77
|
return {
|
|
59
78
|
working: 0,
|
|
@@ -90,7 +109,16 @@ function isEphemeralCodexRootSession(session: SessionModel, now: number): boolea
|
|
|
90
109
|
&& now - session.lastActivity > CODEX_ROOT_EPHEMERAL_GRACE_MS;
|
|
91
110
|
}
|
|
92
111
|
|
|
93
|
-
function isCanonicallyVisible(
|
|
112
|
+
function isCanonicallyVisible(
|
|
113
|
+
session: SessionModel,
|
|
114
|
+
config: ScrylogConfig,
|
|
115
|
+
now: number,
|
|
116
|
+
options: SessionViewOptions,
|
|
117
|
+
): boolean {
|
|
118
|
+
if (session.tool === "unknown" && !options.includeUnknown) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
|
|
94
122
|
if (session.parentSessionId) {
|
|
95
123
|
return session.state === "working" || session.state === "waiting-input";
|
|
96
124
|
}
|
|
@@ -125,6 +153,23 @@ function toProject(project?: ProjectInfo): SessionListProjectInfo | undefined {
|
|
|
125
153
|
|
|
126
154
|
const GRACE_PERIOD_MS = 1500;
|
|
127
155
|
|
|
156
|
+
function resolveDisplayName(session: SessionModel): string | undefined {
|
|
157
|
+
if (session.tmux) {
|
|
158
|
+
return session.paneTitle?.trim()
|
|
159
|
+
|| session.tmux.windowName?.trim()
|
|
160
|
+
|| session.tmux.session?.trim()
|
|
161
|
+
|| undefined;
|
|
162
|
+
}
|
|
163
|
+
if (session.herdr) {
|
|
164
|
+
return session.herdr.name?.trim()
|
|
165
|
+
|| session.herdr.tab?.trim()
|
|
166
|
+
|| session.herdr.agent?.trim()
|
|
167
|
+
|| session.herdr.paneId
|
|
168
|
+
|| undefined;
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
128
173
|
function effectiveState(session: SessionModel, activeChildCount: number, now: number): SessionState {
|
|
129
174
|
if (session.state === "idle" && !session.parentSessionId) {
|
|
130
175
|
if (activeChildCount > 0) {
|
|
@@ -160,8 +205,19 @@ function toListItem(session: SessionModel, childCount: number, activeChildCount:
|
|
|
160
205
|
hasAttention: session.hasAttention,
|
|
161
206
|
detectionSource: session.detectionSource,
|
|
162
207
|
parentSessionId: session.parentSessionId,
|
|
208
|
+
displayName: resolveDisplayName(session),
|
|
163
209
|
project: toProject(session.project),
|
|
164
|
-
tmux: session.tmux,
|
|
210
|
+
tmux: session.tmux ? { ...session.tmux } : undefined,
|
|
211
|
+
herdr: session.herdr ? {
|
|
212
|
+
paneId: session.herdr.paneId,
|
|
213
|
+
terminalId: session.herdr.terminalId,
|
|
214
|
+
workspaceId: session.herdr.workspaceId,
|
|
215
|
+
workspace: session.herdr.workspace,
|
|
216
|
+
tabId: session.herdr.tabId,
|
|
217
|
+
tab: session.herdr.tab,
|
|
218
|
+
name: session.herdr.name,
|
|
219
|
+
agent: session.herdr.agent,
|
|
220
|
+
} : undefined,
|
|
165
221
|
currentPath: session.currentPath,
|
|
166
222
|
activeTool: session.activeTool,
|
|
167
223
|
childCount,
|
|
@@ -179,7 +235,12 @@ export function flattenSessionTree(sessions: SessionTreeItem[]): SessionListItem
|
|
|
179
235
|
return flat;
|
|
180
236
|
}
|
|
181
237
|
|
|
182
|
-
export function buildSessionView(
|
|
238
|
+
export function buildSessionView(
|
|
239
|
+
sessions: SessionModel[],
|
|
240
|
+
config: ScrylogConfig,
|
|
241
|
+
now = Date.now(),
|
|
242
|
+
options: SessionViewOptions = {},
|
|
243
|
+
): SessionView {
|
|
183
244
|
const byId = new Map(sessions.map((session) => [session.id, session]));
|
|
184
245
|
const allChildren = new Map<string, SessionModel[]>();
|
|
185
246
|
const visibleChildren = new Map<string, SessionModel[]>();
|
|
@@ -191,20 +252,20 @@ export function buildSessionView(sessions: SessionModel[], config: ScrylogConfig
|
|
|
191
252
|
arr.push(session);
|
|
192
253
|
allChildren.set(session.parentSessionId, arr);
|
|
193
254
|
|
|
194
|
-
if (isCanonicallyVisible(session, config, now)) {
|
|
255
|
+
if (isCanonicallyVisible(session, config, now, options)) {
|
|
195
256
|
const visibleArr = visibleChildren.get(session.parentSessionId) ?? [];
|
|
196
257
|
visibleArr.push(session);
|
|
197
258
|
visibleChildren.set(session.parentSessionId, visibleArr);
|
|
198
259
|
|
|
199
260
|
const parent = byId.get(session.parentSessionId);
|
|
200
|
-
if (parent) {
|
|
261
|
+
if (parent && isCanonicallyVisible(parent, config, now, options)) {
|
|
201
262
|
visibleRoots.set(parent.id, parent);
|
|
202
263
|
}
|
|
203
264
|
}
|
|
204
265
|
continue;
|
|
205
266
|
}
|
|
206
267
|
|
|
207
|
-
if (isCanonicallyVisible(session, config, now)) {
|
|
268
|
+
if (isCanonicallyVisible(session, config, now, options)) {
|
|
208
269
|
visibleRoots.set(session.id, session);
|
|
209
270
|
}
|
|
210
271
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Non-invasive terminal capability probes used by `scrylog doctor` to explain
|
|
5
|
+
* how provider icons negotiate graphics. Mirrors the response formats OpenTUI's
|
|
6
|
+
* detector expects, but never enters the alternate screen and always restores
|
|
7
|
+
* the terminal to its original state (`finally`).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface TerminalGraphicsCapabilities {
|
|
11
|
+
/** Terminal identification from XTVERSION (e.g. "ghostty(1.2.3)"), if answered. */
|
|
12
|
+
terminalName: string | null;
|
|
13
|
+
/** Outer multiplexer detected from the environment ("tmux" | "zellij" | "none"). */
|
|
14
|
+
multiplexer: "tmux" | "zellij" | "none";
|
|
15
|
+
/** Terminal answered a kitty graphics protocol query. */
|
|
16
|
+
kittyGraphics: boolean;
|
|
17
|
+
/** DA1 primary response advertises sixel (attribute 4). */
|
|
18
|
+
sixel: boolean;
|
|
19
|
+
/** Window pixel resolution from `CSI 14t`. */
|
|
20
|
+
pixelResolution: { width: number; height: number } | null;
|
|
21
|
+
/** stdin was not a TTY (SSH without tty, pipes) or raw mode was unavailable. */
|
|
22
|
+
probeFailed: boolean;
|
|
23
|
+
/** Why the probe did not run or answer. */
|
|
24
|
+
probeFailedReason: "not-a-tty" | "raw-unavailable" | "no-answer" | null;
|
|
25
|
+
/** TERM value, useful for manual triage. */
|
|
26
|
+
term: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PROBE_TIMEOUT_MS = 300;
|
|
30
|
+
|
|
31
|
+
const XTVERSION_PROBE = "\x1bP>0q\x1b\\";
|
|
32
|
+
const DA1_PROBE = "\x1b[c";
|
|
33
|
+
const KITTY_PROBE = "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\";
|
|
34
|
+
const PIXEL_RES_PROBE = "\x1b[14t";
|
|
35
|
+
|
|
36
|
+
/** Visible attributes in a DA1 primary response (e.g. `ESC[?6;22;4c`). Sixel is attribute 4. */
|
|
37
|
+
const DA1_RESPONSE = /\x1b\[\?(\d+(?:;\d+)*)c/g;
|
|
38
|
+
const XTVERSION_RESPONSE = /\x1bP>[|]([^\x1b]+)\x1b\\/;
|
|
39
|
+
const KITTY_RESPONSE = /\x1b_Gi=31;([^\x1b]*)\x1b\\/;
|
|
40
|
+
const PIXEL_RES_RESPONSE = /\x1b\[4;(\d+);(\d+)t/;
|
|
41
|
+
|
|
42
|
+
export function detectMultiplexer(): "tmux" | "zellij" | "none" {
|
|
43
|
+
if (process.env["TMUX"]) return "tmux";
|
|
44
|
+
if (process.env["ZELLIJ"]) return "zellij";
|
|
45
|
+
return "none";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function parseResponses(sequence: string): Partial<TerminalGraphicsCapabilities> {
|
|
49
|
+
const result: Partial<TerminalGraphicsCapabilities> = {};
|
|
50
|
+
const version = sequence.match(XTVERSION_RESPONSE)?.[1];
|
|
51
|
+
if (version) result.terminalName = version.trim();
|
|
52
|
+
for (const da1 of sequence.matchAll(DA1_RESPONSE)) {
|
|
53
|
+
if (da1[1]!.split(";").includes("4")) result.sixel = true;
|
|
54
|
+
}
|
|
55
|
+
const kitty = sequence.match(KITTY_RESPONSE);
|
|
56
|
+
if (kitty) result.kittyGraphics = !(kitty[1] ?? "").startsWith("E:");
|
|
57
|
+
const res = sequence.match(PIXEL_RES_RESPONSE);
|
|
58
|
+
if (res) result.pixelResolution = { width: Number(res[2]), height: Number(res[1]) };
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function probeTerminalGraphics(): Promise<TerminalGraphicsCapabilities> {
|
|
63
|
+
const probe: TerminalGraphicsCapabilities = {
|
|
64
|
+
terminalName: null,
|
|
65
|
+
multiplexer: detectMultiplexer(),
|
|
66
|
+
kittyGraphics: false,
|
|
67
|
+
sixel: false,
|
|
68
|
+
pixelResolution: null,
|
|
69
|
+
probeFailed: true,
|
|
70
|
+
probeFailedReason: null,
|
|
71
|
+
term: process.env["TERM"] ?? null,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
if (!input.isTTY || typeof input.setRawMode !== "function") {
|
|
75
|
+
return Promise.resolve({ ...probe, probeFailedReason: "not-a-tty" });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
const rawActive = input.isRaw === true;
|
|
80
|
+
const restore = () => {
|
|
81
|
+
try { input.setRawMode(rawActive); } catch { /* restore best effort */ }
|
|
82
|
+
};
|
|
83
|
+
let buffer = "";
|
|
84
|
+
let settleTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
85
|
+
let done = false;
|
|
86
|
+
const stop = () => {
|
|
87
|
+
if (done) return;
|
|
88
|
+
done = true;
|
|
89
|
+
clearTimeout(settleTimeout!);
|
|
90
|
+
input.removeListener("data", onData);
|
|
91
|
+
restore();
|
|
92
|
+
};
|
|
93
|
+
const settle = () => {
|
|
94
|
+
stop();
|
|
95
|
+
const parsed = parseResponses(buffer);
|
|
96
|
+
const failed = Object.keys(parsed).length === 0;
|
|
97
|
+
resolve({
|
|
98
|
+
...probe,
|
|
99
|
+
...parsed,
|
|
100
|
+
probeFailed: failed,
|
|
101
|
+
probeFailedReason: failed ? "no-answer" : null,
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
const onData = (chunk: Buffer | string) => {
|
|
105
|
+
buffer += typeof chunk === "string" ? chunk : chunk.toString("latin1");
|
|
106
|
+
clearTimeout(settleTimeout!);
|
|
107
|
+
settleTimeout = setTimeout(settle, 80);
|
|
108
|
+
};
|
|
109
|
+
try {
|
|
110
|
+
input.setRawMode(true);
|
|
111
|
+
} catch {
|
|
112
|
+
resolve({ ...probe, probeFailedReason: "raw-unavailable" });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
input.on("data", onData);
|
|
116
|
+
settleTimeout = setTimeout(settle, PROBE_TIMEOUT_MS);
|
|
117
|
+
output.write(XTVERSION_PROBE + DA1_PROBE + KITTY_PROBE + PIXEL_RES_PROBE);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type ProviderIconAdvice = "graphics" | "kitty" | "sixel" | "blocks";
|
|
122
|
+
|
|
123
|
+
/** Maps probed capabilities to the recommended `tui.providerIcons` value. */
|
|
124
|
+
export function adviseProviderIcons(capabilities: TerminalGraphicsCapabilities): ProviderIconAdvice {
|
|
125
|
+
if (capabilities.multiplexer === "tmux" || capabilities.multiplexer === "zellij") return "blocks";
|
|
126
|
+
if (capabilities.kittyGraphics) return "graphics";
|
|
127
|
+
if (capabilities.sixel && capabilities.pixelResolution) return "graphics";
|
|
128
|
+
if (capabilities.sixel) return "sixel";
|
|
129
|
+
return "blocks";
|
|
130
|
+
}
|
package/src/shared/types.ts
CHANGED
|
@@ -131,7 +131,7 @@ export interface AppOptions extends CollectorOptions {
|
|
|
131
131
|
// ── New unified session model ─────────────────────────────────────────────────
|
|
132
132
|
|
|
133
133
|
/** How the agent session was detected */
|
|
134
|
-
export type SessionSource = "tmux" | "standalone";
|
|
134
|
+
export type SessionSource = "tmux" | "herdr" | "standalone";
|
|
135
135
|
|
|
136
136
|
/** The tool type — extended with "claude" and "error" variants */
|
|
137
137
|
export type SessionTool = "opencode" | "claude" | "codex" | "copilotcli" | "shell" | "unknown";
|
|
@@ -190,6 +190,23 @@ export interface CodexSessionMeta {
|
|
|
190
190
|
forkedFromId?: string;
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
+
/** Herdr pane and agent metadata */
|
|
194
|
+
export interface HerdrSessionMeta {
|
|
195
|
+
paneId: string;
|
|
196
|
+
terminalId?: string;
|
|
197
|
+
workspaceId?: string;
|
|
198
|
+
workspace?: string;
|
|
199
|
+
tabId?: string;
|
|
200
|
+
tab?: string;
|
|
201
|
+
/** Explicit pane name — undefined when herdr only derived it from the agent name */
|
|
202
|
+
name?: string;
|
|
203
|
+
focused?: boolean;
|
|
204
|
+
providerSource: "socket" | "cli";
|
|
205
|
+
agent?: string;
|
|
206
|
+
agentStatus?: string;
|
|
207
|
+
agentSessionId?: string;
|
|
208
|
+
}
|
|
209
|
+
|
|
193
210
|
/** Unified session model used throughout daemon, API, MCP, and TUI */
|
|
194
211
|
export interface SessionModel {
|
|
195
212
|
id: string;
|
|
@@ -201,6 +218,8 @@ export interface SessionModel {
|
|
|
201
218
|
session: string;
|
|
202
219
|
window: string;
|
|
203
220
|
pane: string;
|
|
221
|
+
/** tmux window (tab) name, used as display-name fallback */
|
|
222
|
+
windowName?: string;
|
|
204
223
|
};
|
|
205
224
|
summary: string;
|
|
206
225
|
lastActivity: number;
|
|
@@ -212,10 +231,12 @@ export interface SessionModel {
|
|
|
212
231
|
claude?: ClaudeSessionMeta;
|
|
213
232
|
/** Populated when detected via Codex JSONL transcript or hooks */
|
|
214
233
|
codex?: CodexSessionMeta;
|
|
234
|
+
/** Populated for panes discovered through herdr */
|
|
235
|
+
herdr?: HerdrSessionMeta;
|
|
215
236
|
/** Populated when detected via legacy heuristics */
|
|
216
237
|
legacyPaneId?: string;
|
|
217
238
|
/** Data source description for UI display */
|
|
218
|
-
detectionSource?: "opencode-sdk" | "opencode-hook" | "claude-transcript" | "claude-hook" | "codex-transcript" | "codex-hook" | "fallback-detection" | "opencode-db";
|
|
239
|
+
detectionSource?: "opencode-sdk" | "opencode-hook" | "claude-transcript" | "claude-hook" | "codex-transcript" | "codex-hook" | "fallback-detection" | "opencode-db" | "herdr";
|
|
219
240
|
/** ID of the parent session when this is a sub-agent session spawned by another session */
|
|
220
241
|
parentSessionId?: string;
|
|
221
242
|
/** IDs of direct child sessions spawned by this session (populated on parent sessions) */
|
|
@@ -278,6 +299,16 @@ export interface DaemonState {
|
|
|
278
299
|
refreshedAt: number;
|
|
279
300
|
}
|
|
280
301
|
|
|
302
|
+
export interface HealthResponse {
|
|
303
|
+
ok: true;
|
|
304
|
+
version: string;
|
|
305
|
+
startedAt: number;
|
|
306
|
+
refreshedAt: number;
|
|
307
|
+
collectors: Record<string, CollectorStatus>;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export type DaemonHealth = Pick<HealthResponse, "ok"> & Partial<Omit<HealthResponse, "ok">>;
|
|
311
|
+
|
|
281
312
|
export interface CollectorStatus {
|
|
282
313
|
name: string;
|
|
283
314
|
running: boolean;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface DaemonVersionMismatch {
|
|
5
|
+
cliVersion: string;
|
|
6
|
+
daemonVersion?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function readInstalledVersion(): string {
|
|
10
|
+
try {
|
|
11
|
+
const pkg = JSON.parse(readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8")) as { version?: unknown };
|
|
12
|
+
return typeof pkg.version === "string" && pkg.version.length > 0 ? pkg.version : "unknown";
|
|
13
|
+
} catch {
|
|
14
|
+
return "unknown";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const INSTALLED_VERSION = readInstalledVersion();
|
|
19
|
+
|
|
20
|
+
export function getInstalledVersion(): string {
|
|
21
|
+
return INSTALLED_VERSION;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getDaemonVersionMismatch(cliVersion: string, daemonVersion?: string): DaemonVersionMismatch | undefined {
|
|
25
|
+
if (cliVersion === "unknown") return undefined;
|
|
26
|
+
if (!daemonVersion || daemonVersion === "unknown") return { cliVersion };
|
|
27
|
+
if (daemonVersion === cliVersion) return undefined;
|
|
28
|
+
return { cliVersion, daemonVersion };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function formatVersionDiagnostics(cliVersion: string, daemonVersion?: string): string {
|
|
32
|
+
const normalizedDaemonVersion = daemonVersion && daemonVersion !== "unknown" ? daemonVersion : "unknown";
|
|
33
|
+
const mismatch = getDaemonVersionMismatch(cliVersion, daemonVersion);
|
|
34
|
+
const lines = [
|
|
35
|
+
`CLI version: ${cliVersion}`,
|
|
36
|
+
`Daemon version: ${normalizedDaemonVersion}`,
|
|
37
|
+
`Version match: ${mismatch ? "false" : "true"}`,
|
|
38
|
+
];
|
|
39
|
+
if (mismatch) {
|
|
40
|
+
lines.push("Action: scrylog daemon restart");
|
|
41
|
+
lines.push("Fallback: stop the running daemon and run scrylogd");
|
|
42
|
+
}
|
|
43
|
+
return `${lines.join("\n")}\n`;
|
|
44
|
+
}
|