@colmbus72/yeehaw 0.4.1 → 0.5.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/claude-plugin/.claude-plugin/plugin.json +2 -1
- package/claude-plugin/hooks/hooks.json +41 -0
- package/claude-plugin/hooks/session-status.sh +13 -0
- package/dist/app.js +80 -31
- package/dist/components/HelpOverlay.js +4 -2
- package/dist/components/List.d.ts +8 -1
- package/dist/components/List.js +14 -5
- package/dist/components/Panel.js +27 -1
- package/dist/components/SplashScreen.js +1 -1
- package/dist/hooks/useSessions.js +2 -2
- package/dist/index.js +41 -1
- package/dist/lib/config.d.ts +13 -1
- package/dist/lib/config.js +51 -0
- package/dist/lib/critters.d.ts +28 -0
- package/dist/lib/critters.js +201 -0
- package/dist/lib/hooks.d.ts +20 -0
- package/dist/lib/hooks.js +91 -0
- package/dist/lib/hotkeys.js +24 -20
- package/dist/lib/paths.d.ts +2 -0
- package/dist/lib/paths.js +2 -0
- package/dist/lib/signals.d.ts +30 -0
- package/dist/lib/signals.js +104 -0
- package/dist/lib/tmux.d.ts +8 -2
- package/dist/lib/tmux.js +69 -18
- package/dist/mcp-server.js +161 -1
- package/dist/types.d.ts +4 -2
- package/dist/views/BarnContext.d.ts +4 -2
- package/dist/views/BarnContext.js +79 -20
- package/dist/views/GlobalDashboard.d.ts +2 -2
- package/dist/views/GlobalDashboard.js +20 -18
- package/dist/views/LivestockDetailView.js +11 -7
- package/dist/views/ProjectContext.d.ts +2 -2
- package/dist/views/ProjectContext.js +33 -24
- package/package.json +5 -5
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
import { buildSshCommand } from './livestock.js';
|
|
3
|
+
import { shellEscape } from './shell.js';
|
|
4
|
+
import { getErrorMessage } from './errors.js';
|
|
5
|
+
/**
|
|
6
|
+
* Services that are interesting for developers (databases, web servers, etc.)
|
|
7
|
+
*/
|
|
8
|
+
const INTERESTING_SERVICE_PATTERNS = [
|
|
9
|
+
// Databases
|
|
10
|
+
'mysql', 'mariadb', 'postgres', 'postgresql', 'mongodb', 'mongo',
|
|
11
|
+
// Caches
|
|
12
|
+
'redis', 'memcached',
|
|
13
|
+
// Web servers
|
|
14
|
+
'nginx', 'apache', 'httpd', 'caddy',
|
|
15
|
+
// PHP
|
|
16
|
+
'php-fpm', 'php7', 'php8',
|
|
17
|
+
// Python
|
|
18
|
+
'gunicorn', 'uvicorn', 'celery',
|
|
19
|
+
// Node
|
|
20
|
+
'node', 'pm2',
|
|
21
|
+
// Mail
|
|
22
|
+
'postfix', 'dovecot',
|
|
23
|
+
// Queue
|
|
24
|
+
'rabbitmq', 'kafka',
|
|
25
|
+
// Search
|
|
26
|
+
'elasticsearch', 'opensearch', 'meilisearch',
|
|
27
|
+
// Other
|
|
28
|
+
'supervisor', 'docker',
|
|
29
|
+
];
|
|
30
|
+
/**
|
|
31
|
+
* Check if a service name matches any interesting pattern
|
|
32
|
+
*/
|
|
33
|
+
function isInterestingService(serviceName) {
|
|
34
|
+
const lower = serviceName.toLowerCase();
|
|
35
|
+
return INTERESTING_SERVICE_PATTERNS.some(pattern => lower.includes(pattern));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Extract a friendly name from a service name
|
|
39
|
+
* e.g., "mysql.service" -> "mysql", "php8.1-fpm.service" -> "php8.1-fpm"
|
|
40
|
+
*/
|
|
41
|
+
function extractServiceName(service) {
|
|
42
|
+
return service.replace(/\.service$/, '');
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Read logs from a critter (via journald or custom path)
|
|
46
|
+
*/
|
|
47
|
+
export async function readCritterLogs(critter, barn, options = {}) {
|
|
48
|
+
const { lines = 100, pattern } = options;
|
|
49
|
+
const escapedLines = String(lines);
|
|
50
|
+
let cmd;
|
|
51
|
+
if (critter.use_journald !== false) {
|
|
52
|
+
// Use journalctl
|
|
53
|
+
const escapedService = shellEscape(critter.service);
|
|
54
|
+
cmd = `journalctl -u ${escapedService} -n ${escapedLines} --no-pager`;
|
|
55
|
+
if (pattern) {
|
|
56
|
+
const escapedPattern = shellEscape(pattern);
|
|
57
|
+
cmd += ` | grep -i ${escapedPattern} || true`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (critter.log_path) {
|
|
61
|
+
// Use custom log path
|
|
62
|
+
const escapedLogPath = shellEscape(critter.log_path);
|
|
63
|
+
cmd = `tail -n ${escapedLines} ${escapedLogPath}`;
|
|
64
|
+
if (pattern) {
|
|
65
|
+
const escapedPattern = shellEscape(pattern);
|
|
66
|
+
cmd += ` | grep -i ${escapedPattern} || true`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
return { content: '', error: 'Critter has no log_path and use_journald is disabled' };
|
|
71
|
+
}
|
|
72
|
+
// Local barn
|
|
73
|
+
if (barn.name === 'local') {
|
|
74
|
+
try {
|
|
75
|
+
const result = await execa('sh', ['-c', cmd]);
|
|
76
|
+
if (!result.stdout.trim()) {
|
|
77
|
+
return { content: '', error: `No logs found for ${critter.name}` };
|
|
78
|
+
}
|
|
79
|
+
return { content: result.stdout };
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
return { content: '', error: `Failed to read logs: ${getErrorMessage(err)}` };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Remote barn - SSH
|
|
86
|
+
if (!barn.host || !barn.user) {
|
|
87
|
+
return { content: '', error: `Barn '${barn.name}' is not configured for SSH` };
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const sshArgs = buildSshCommand(barn);
|
|
91
|
+
const result = await execa(sshArgs[0], [...sshArgs.slice(1), cmd]);
|
|
92
|
+
if (!result.stdout.trim()) {
|
|
93
|
+
return { content: '', error: `No logs found for ${critter.name}` };
|
|
94
|
+
}
|
|
95
|
+
return { content: result.stdout };
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
return { content: '', error: `SSH error: ${getErrorMessage(err)}` };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Parse systemctl show output into key-value pairs
|
|
103
|
+
*/
|
|
104
|
+
function parseSystemctlShow(output) {
|
|
105
|
+
const result = {};
|
|
106
|
+
for (const line of output.split('\n')) {
|
|
107
|
+
const eqIndex = line.indexOf('=');
|
|
108
|
+
if (eqIndex !== -1) {
|
|
109
|
+
const key = line.slice(0, eqIndex);
|
|
110
|
+
const value = line.slice(eqIndex + 1);
|
|
111
|
+
result[key] = value;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Discover critters (running services) on a barn
|
|
118
|
+
*/
|
|
119
|
+
export async function discoverCritters(barn) {
|
|
120
|
+
// Command to list running services
|
|
121
|
+
const listCmd = 'systemctl list-units --type=service --state=running --no-pager --plain';
|
|
122
|
+
let output;
|
|
123
|
+
// Local barn
|
|
124
|
+
if (barn.name === 'local') {
|
|
125
|
+
try {
|
|
126
|
+
const result = await execa('sh', ['-c', listCmd]);
|
|
127
|
+
output = result.stdout;
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
return { critters: [], error: `Failed to list services: ${getErrorMessage(err)}` };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
// Remote barn - SSH
|
|
135
|
+
if (!barn.host || !barn.user) {
|
|
136
|
+
return { critters: [], error: `Barn '${barn.name}' is not configured for SSH` };
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const sshArgs = buildSshCommand(barn);
|
|
140
|
+
const result = await execa(sshArgs[0], [...sshArgs.slice(1), listCmd]);
|
|
141
|
+
output = result.stdout;
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
return { critters: [], error: `SSH error: ${getErrorMessage(err)}` };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Parse the output - each line: "unit.service loaded active running description"
|
|
148
|
+
const lines = output.split('\n').filter(line => line.trim());
|
|
149
|
+
const discovered = [];
|
|
150
|
+
for (const line of lines) {
|
|
151
|
+
const parts = line.trim().split(/\s+/);
|
|
152
|
+
if (parts.length < 1)
|
|
153
|
+
continue;
|
|
154
|
+
const service = parts[0];
|
|
155
|
+
if (!service.endsWith('.service'))
|
|
156
|
+
continue;
|
|
157
|
+
if (!isInterestingService(service))
|
|
158
|
+
continue;
|
|
159
|
+
const suggested_name = extractServiceName(service);
|
|
160
|
+
// Try to get more details about this service
|
|
161
|
+
let binary;
|
|
162
|
+
let config_path;
|
|
163
|
+
const showCmd = `systemctl show ${shellEscape(service)} --property=ExecStart`;
|
|
164
|
+
try {
|
|
165
|
+
let showOutput;
|
|
166
|
+
if (barn.name === 'local') {
|
|
167
|
+
const showResult = await execa('sh', ['-c', showCmd]);
|
|
168
|
+
showOutput = showResult.stdout;
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
const sshArgs = buildSshCommand(barn);
|
|
172
|
+
const showResult = await execa(sshArgs[0], [...sshArgs.slice(1), showCmd]);
|
|
173
|
+
showOutput = showResult.stdout;
|
|
174
|
+
}
|
|
175
|
+
const props = parseSystemctlShow(showOutput);
|
|
176
|
+
// Extract binary from ExecStart (format: { path=/usr/bin/mysqld ; argv[]=... })
|
|
177
|
+
if (props.ExecStart) {
|
|
178
|
+
const pathMatch = props.ExecStart.match(/path=([^\s;]+)/);
|
|
179
|
+
if (pathMatch) {
|
|
180
|
+
binary = pathMatch[1];
|
|
181
|
+
}
|
|
182
|
+
// Try to find config flags like --config= or -c
|
|
183
|
+
const configMatch = props.ExecStart.match(/--config[=\s]([^\s;]+)/);
|
|
184
|
+
if (configMatch) {
|
|
185
|
+
config_path = configMatch[1];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Ignore errors getting details - just use basic info
|
|
191
|
+
}
|
|
192
|
+
discovered.push({
|
|
193
|
+
service,
|
|
194
|
+
suggested_name,
|
|
195
|
+
binary,
|
|
196
|
+
config_path,
|
|
197
|
+
status: 'running',
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return { critters: discovered };
|
|
201
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install the Claude hook script to ~/.yeehaw/bin/
|
|
3
|
+
*/
|
|
4
|
+
export declare function installHookScript(): string;
|
|
5
|
+
/**
|
|
6
|
+
* Get the path to the hook script
|
|
7
|
+
*/
|
|
8
|
+
export declare function getHookScriptPath(): string;
|
|
9
|
+
/**
|
|
10
|
+
* Get the Claude settings.json hooks configuration
|
|
11
|
+
*/
|
|
12
|
+
export declare function getClaudeHooksConfig(): object;
|
|
13
|
+
/**
|
|
14
|
+
* Check if Claude hooks are already configured
|
|
15
|
+
*/
|
|
16
|
+
export declare function checkClaudeHooksInstalled(): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Check if hook script exists
|
|
19
|
+
*/
|
|
20
|
+
export declare function hookScriptExists(): boolean;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync, chmodSync, readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { HOOKS_DIR, SIGNALS_DIR } from './paths.js';
|
|
5
|
+
const HOOK_SCRIPT_NAME = 'claude-hook';
|
|
6
|
+
const HOOK_SCRIPT_CONTENT = `#!/bin/bash
|
|
7
|
+
# Yeehaw Claude Hook - writes session status for the CLI to read
|
|
8
|
+
STATUS="$1"
|
|
9
|
+
PANE_ID="\${TMUX_PANE:-unknown}"
|
|
10
|
+
SIGNAL_DIR="$HOME/.yeehaw/session-signals"
|
|
11
|
+
SIGNAL_FILE="$SIGNAL_DIR/\${PANE_ID//[^a-zA-Z0-9]/_}.json"
|
|
12
|
+
|
|
13
|
+
mkdir -p "$SIGNAL_DIR"
|
|
14
|
+
cat > "$SIGNAL_FILE" << EOF
|
|
15
|
+
{"status":"$STATUS","updated":$(date +%s)}
|
|
16
|
+
EOF
|
|
17
|
+
`;
|
|
18
|
+
/**
|
|
19
|
+
* Install the Claude hook script to ~/.yeehaw/bin/
|
|
20
|
+
*/
|
|
21
|
+
export function installHookScript() {
|
|
22
|
+
// Ensure directories exist
|
|
23
|
+
if (!existsSync(HOOKS_DIR)) {
|
|
24
|
+
mkdirSync(HOOKS_DIR, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
if (!existsSync(SIGNALS_DIR)) {
|
|
27
|
+
mkdirSync(SIGNALS_DIR, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
const scriptPath = join(HOOKS_DIR, HOOK_SCRIPT_NAME);
|
|
30
|
+
writeFileSync(scriptPath, HOOK_SCRIPT_CONTENT, 'utf-8');
|
|
31
|
+
chmodSync(scriptPath, 0o755);
|
|
32
|
+
return scriptPath;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Get the path to the hook script
|
|
36
|
+
*/
|
|
37
|
+
export function getHookScriptPath() {
|
|
38
|
+
return join(HOOKS_DIR, HOOK_SCRIPT_NAME);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Get the Claude settings.json hooks configuration
|
|
42
|
+
*/
|
|
43
|
+
export function getClaudeHooksConfig() {
|
|
44
|
+
const hookPath = join(HOOKS_DIR, HOOK_SCRIPT_NAME);
|
|
45
|
+
return {
|
|
46
|
+
hooks: {
|
|
47
|
+
PreToolUse: [
|
|
48
|
+
{
|
|
49
|
+
matcher: '*',
|
|
50
|
+
hooks: [`${hookPath} working`],
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
Stop: [
|
|
54
|
+
{
|
|
55
|
+
matcher: '*',
|
|
56
|
+
hooks: [`${hookPath} waiting`],
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
Notification: [
|
|
60
|
+
{
|
|
61
|
+
matcher: 'idle_prompt',
|
|
62
|
+
hooks: [`${hookPath} waiting`],
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Check if Claude hooks are already configured
|
|
70
|
+
*/
|
|
71
|
+
export function checkClaudeHooksInstalled() {
|
|
72
|
+
const claudeSettingsPath = join(homedir(), '.claude', 'settings.json');
|
|
73
|
+
if (!existsSync(claudeSettingsPath)) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const content = readFileSync(claudeSettingsPath, 'utf-8');
|
|
78
|
+
const settings = JSON.parse(content);
|
|
79
|
+
return settings.hooks?.PreToolUse?.some((h) => h.hooks?.some((cmd) => cmd.includes('yeehaw')));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Check if hook script exists
|
|
87
|
+
*/
|
|
88
|
+
export function hookScriptExists() {
|
|
89
|
+
const scriptPath = join(HOOKS_DIR, HOOK_SCRIPT_NAME);
|
|
90
|
+
return existsSync(scriptPath);
|
|
91
|
+
}
|
package/dist/lib/hotkeys.js
CHANGED
|
@@ -2,38 +2,42 @@
|
|
|
2
2
|
export const HOTKEYS = [
|
|
3
3
|
// === GLOBAL (everywhere) ===
|
|
4
4
|
{ key: '?', description: 'Toggle help', category: 'system', scopes: ['global'] },
|
|
5
|
-
{ key: 'q', description: '
|
|
5
|
+
{ key: 'q', description: 'Detach', category: 'system', scopes: ['global-dashboard'] },
|
|
6
|
+
{ key: 'Q', description: 'Quit Yeehaw', category: 'system', scopes: ['global-dashboard'] },
|
|
6
7
|
{ key: 'Esc', description: 'Back / Cancel', category: 'system', scopes: ['global'] },
|
|
7
8
|
// === LIST NAVIGATION (any focused list) ===
|
|
8
|
-
{ key: 'j/k', description: '
|
|
9
|
-
{ key: 'g/G', description: '
|
|
10
|
-
{ key: 'Enter', description: '
|
|
9
|
+
{ key: 'j/k', description: 'Move up/down', category: 'navigation', scopes: ['list'] },
|
|
10
|
+
{ key: 'g/G', description: 'Jump to first/last', category: 'navigation', scopes: ['list'] },
|
|
11
|
+
{ key: 'Enter', description: 'Open selected item', category: 'navigation', scopes: ['list'] },
|
|
11
12
|
// === CONTENT NAVIGATION (markdown panels) ===
|
|
12
13
|
{ key: 'j/k', description: 'Scroll up/down', category: 'navigation', scopes: ['content'] },
|
|
13
14
|
{ key: 'g/G', description: 'Jump to top/bottom', category: 'navigation', scopes: ['content'] },
|
|
14
15
|
{ key: 'PgUp/PgDn', description: 'Scroll page', category: 'navigation', scopes: ['content'] },
|
|
15
|
-
// ===
|
|
16
|
+
// === PANEL NAVIGATION ===
|
|
16
17
|
{ key: 'Tab', description: 'Switch panel', category: 'navigation', scopes: ['global-dashboard', 'project-context', 'barn-context', 'wiki-view', 'issues-view'] },
|
|
17
|
-
|
|
18
|
-
{ key: '
|
|
19
|
-
|
|
20
|
-
{ key: '
|
|
21
|
-
|
|
18
|
+
// === GLOBAL SESSION SWITCHING ===
|
|
19
|
+
{ key: '1-9', description: 'Switch to session', category: 'action', scopes: ['global-dashboard', 'project-context'] },
|
|
20
|
+
// === ROW-LEVEL ACTIONS (shown on selected rows) ===
|
|
21
|
+
{ key: 'c', description: 'Claude session (at path)', category: 'action', scopes: ['global-dashboard'], panel: 'projects' },
|
|
22
|
+
{ key: 'c', description: 'Claude session (at path)', category: 'action', scopes: ['project-context'], panel: 'livestock' },
|
|
23
|
+
{ key: 's', description: 'Shell into server', category: 'action', scopes: ['global-dashboard'], panel: 'barns' },
|
|
24
|
+
{ key: 's', description: 'Shell into server', category: 'action', scopes: ['project-context', 'barn-context'], panel: 'livestock' },
|
|
25
|
+
// === CARD/PAGE-LEVEL ACTIONS ===
|
|
22
26
|
{ key: 'n', description: 'New (in focused panel)', category: 'action', scopes: ['global-dashboard', 'project-context', 'barn-context', 'wiki-view'] },
|
|
23
|
-
{ key: 'e', description: 'Edit
|
|
24
|
-
{ key: 'd', description: 'Delete
|
|
25
|
-
{ key: 'D', description: 'Delete container
|
|
26
|
-
// === PROJECT CONTEXT ===
|
|
27
|
+
{ key: 'e', description: 'Edit', category: 'action', scopes: ['project-context', 'barn-context', 'wiki-view', 'livestock-detail'] },
|
|
28
|
+
{ key: 'd', description: 'Delete', category: 'action', scopes: ['project-context', 'barn-context', 'wiki-view'] },
|
|
29
|
+
{ key: 'D', description: 'Delete container', category: 'action', scopes: ['project-context', 'barn-context'] },
|
|
30
|
+
// === PROJECT CONTEXT PAGE-LEVEL ===
|
|
27
31
|
{ key: 'w', description: 'Open wiki', category: 'action', scopes: ['project-context'] },
|
|
28
32
|
{ key: 'i', description: 'Open issues', category: 'action', scopes: ['project-context'] },
|
|
33
|
+
// === LIVESTOCK DETAIL PAGE-LEVEL ===
|
|
34
|
+
{ key: 'c', description: 'Claude session', category: 'action', scopes: ['livestock-detail'] },
|
|
35
|
+
{ key: 's', description: 'Shell session', category: 'action', scopes: ['livestock-detail'] },
|
|
36
|
+
{ key: 'l', description: 'View logs', category: 'action', scopes: ['livestock-detail'] },
|
|
29
37
|
// === ISSUES VIEW ===
|
|
30
|
-
{ key: 'r', description: 'Refresh
|
|
38
|
+
{ key: 'r', description: 'Refresh', category: 'action', scopes: ['issues-view', 'logs-view'] },
|
|
31
39
|
{ key: 'o', description: 'Open in browser', category: 'action', scopes: ['issues-view'] },
|
|
32
|
-
// ===
|
|
33
|
-
{ key: 'l', description: 'View logs', category: 'action', scopes: ['livestock-detail'] },
|
|
34
|
-
// === LOGS VIEW ===
|
|
35
|
-
{ key: 'r', description: 'Refresh logs', category: 'action', scopes: ['logs-view'] },
|
|
36
|
-
// === NIGHT SKY (screensaver) ===
|
|
40
|
+
// === NIGHT SKY ===
|
|
37
41
|
{ key: 'v', description: 'Visualizer', category: 'navigation', scopes: ['global-dashboard'] },
|
|
38
42
|
{ key: 'c', description: 'Spawn cloud', category: 'action', scopes: ['night-sky'] },
|
|
39
43
|
{ key: 'r', description: 'Randomize', category: 'action', scopes: ['night-sky'] },
|
package/dist/lib/paths.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export declare const CONFIG_FILE: string;
|
|
|
3
3
|
export declare const PROJECTS_DIR: string;
|
|
4
4
|
export declare const BARNS_DIR: string;
|
|
5
5
|
export declare const SESSIONS_DIR: string;
|
|
6
|
+
export declare const SIGNALS_DIR: string;
|
|
7
|
+
export declare const HOOKS_DIR: string;
|
|
6
8
|
export declare function getProjectPath(name: string): string;
|
|
7
9
|
export declare function getBarnPath(name: string): string;
|
|
8
10
|
export declare function getSessionPath(id: string): string;
|
package/dist/lib/paths.js
CHANGED
|
@@ -5,6 +5,8 @@ export const CONFIG_FILE = join(YEEHAW_DIR, 'config.yaml');
|
|
|
5
5
|
export const PROJECTS_DIR = join(YEEHAW_DIR, 'projects');
|
|
6
6
|
export const BARNS_DIR = join(YEEHAW_DIR, 'barns');
|
|
7
7
|
export const SESSIONS_DIR = join(YEEHAW_DIR, 'sessions');
|
|
8
|
+
export const SIGNALS_DIR = join(YEEHAW_DIR, 'session-signals');
|
|
9
|
+
export const HOOKS_DIR = join(YEEHAW_DIR, 'bin');
|
|
8
10
|
/**
|
|
9
11
|
* Validate a name to prevent path traversal attacks.
|
|
10
12
|
* Rejects names containing path separators or parent directory references.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type SessionStatus = 'working' | 'waiting' | 'idle' | 'error';
|
|
2
|
+
export interface SessionSignal {
|
|
3
|
+
status: SessionStatus;
|
|
4
|
+
updated: number;
|
|
5
|
+
}
|
|
6
|
+
export interface WindowStatusInfo {
|
|
7
|
+
text: string;
|
|
8
|
+
status: SessionStatus;
|
|
9
|
+
icon: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Read signal file for a tmux pane
|
|
13
|
+
*/
|
|
14
|
+
export declare function readSignal(paneId: string): SessionSignal | null;
|
|
15
|
+
/**
|
|
16
|
+
* Get status icon for a session status
|
|
17
|
+
*/
|
|
18
|
+
export declare function getStatusIcon(status: SessionStatus): string;
|
|
19
|
+
/**
|
|
20
|
+
* Ensure the signals directory exists
|
|
21
|
+
*/
|
|
22
|
+
export declare function ensureSignalsDir(): void;
|
|
23
|
+
/**
|
|
24
|
+
* Clean up signal file for a pane
|
|
25
|
+
*/
|
|
26
|
+
export declare function cleanupSignal(paneId: string): void;
|
|
27
|
+
/**
|
|
28
|
+
* Clean up all stale signal files (older than 1 hour)
|
|
29
|
+
*/
|
|
30
|
+
export declare function cleanupStaleSignals(): void;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, unlinkSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { SIGNALS_DIR } from './paths.js';
|
|
4
|
+
const STATUS_ICONS = {
|
|
5
|
+
working: '⠿',
|
|
6
|
+
waiting: '◆',
|
|
7
|
+
idle: '○',
|
|
8
|
+
error: '✖',
|
|
9
|
+
};
|
|
10
|
+
const SIGNAL_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
|
|
11
|
+
/**
|
|
12
|
+
* Sanitize pane ID to create a safe filename
|
|
13
|
+
*/
|
|
14
|
+
function sanitizePaneId(paneId) {
|
|
15
|
+
return paneId.replace(/[^a-zA-Z0-9]/g, '_');
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Read signal file for a tmux pane
|
|
19
|
+
*/
|
|
20
|
+
export function readSignal(paneId) {
|
|
21
|
+
if (!existsSync(SIGNALS_DIR))
|
|
22
|
+
return null;
|
|
23
|
+
const filename = `${sanitizePaneId(paneId)}.json`;
|
|
24
|
+
const filepath = join(SIGNALS_DIR, filename);
|
|
25
|
+
if (!existsSync(filepath))
|
|
26
|
+
return null;
|
|
27
|
+
try {
|
|
28
|
+
const content = readFileSync(filepath, 'utf-8');
|
|
29
|
+
const signal = JSON.parse(content);
|
|
30
|
+
// Check if signal is stale
|
|
31
|
+
const ageMs = Date.now() - signal.updated * 1000;
|
|
32
|
+
if (ageMs > SIGNAL_MAX_AGE_MS) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return signal;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Get status icon for a session status
|
|
43
|
+
*/
|
|
44
|
+
export function getStatusIcon(status) {
|
|
45
|
+
return STATUS_ICONS[status];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Ensure the signals directory exists
|
|
49
|
+
*/
|
|
50
|
+
export function ensureSignalsDir() {
|
|
51
|
+
if (!existsSync(SIGNALS_DIR)) {
|
|
52
|
+
mkdirSync(SIGNALS_DIR, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Clean up signal file for a pane
|
|
57
|
+
*/
|
|
58
|
+
export function cleanupSignal(paneId) {
|
|
59
|
+
const filename = `${sanitizePaneId(paneId)}.json`;
|
|
60
|
+
const filepath = join(SIGNALS_DIR, filename);
|
|
61
|
+
try {
|
|
62
|
+
if (existsSync(filepath)) {
|
|
63
|
+
unlinkSync(filepath);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Ignore cleanup errors
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Clean up all stale signal files (older than 1 hour)
|
|
72
|
+
*/
|
|
73
|
+
export function cleanupStaleSignals() {
|
|
74
|
+
if (!existsSync(SIGNALS_DIR))
|
|
75
|
+
return;
|
|
76
|
+
const STALE_AGE_MS = 60 * 60 * 1000; // 1 hour
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
try {
|
|
79
|
+
const files = readdirSync(SIGNALS_DIR).filter(f => f.endsWith('.json'));
|
|
80
|
+
for (const file of files) {
|
|
81
|
+
const filepath = join(SIGNALS_DIR, file);
|
|
82
|
+
try {
|
|
83
|
+
const content = readFileSync(filepath, 'utf-8');
|
|
84
|
+
const signal = JSON.parse(content);
|
|
85
|
+
const ageMs = now - signal.updated * 1000;
|
|
86
|
+
if (ageMs > STALE_AGE_MS) {
|
|
87
|
+
unlinkSync(filepath);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Delete malformed files
|
|
92
|
+
try {
|
|
93
|
+
unlinkSync(filepath);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Ignore
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Ignore errors
|
|
103
|
+
}
|
|
104
|
+
}
|
package/dist/lib/tmux.d.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
+
import { type WindowStatusInfo } from './signals.js';
|
|
2
|
+
export type { WindowStatusInfo, SessionStatus } from './signals.js';
|
|
1
3
|
export declare const YEEHAW_SESSION = "yeehaw";
|
|
4
|
+
export type WindowType = 'claude' | 'shell' | 'ssh' | '';
|
|
2
5
|
export interface TmuxWindow {
|
|
3
6
|
index: number;
|
|
4
7
|
name: string;
|
|
5
8
|
active: boolean;
|
|
9
|
+
paneId: string;
|
|
6
10
|
paneTitle: string;
|
|
7
11
|
paneCurrentCommand: string;
|
|
8
12
|
windowActivity: number;
|
|
13
|
+
type: WindowType;
|
|
9
14
|
}
|
|
10
15
|
export declare function hasTmux(): boolean;
|
|
11
16
|
export declare function isInsideYeehawSession(): boolean;
|
|
@@ -23,13 +28,14 @@ export declare function createShellWindow(workingDir: string, windowName: string
|
|
|
23
28
|
export declare function createSshWindow(windowName: string, host: string, user: string, port: number, identityFile: string, remotePath: string): number;
|
|
24
29
|
export declare function detachFromSession(): void;
|
|
25
30
|
export declare function killYeehawSession(): void;
|
|
31
|
+
export declare function restartYeehaw(): void;
|
|
26
32
|
export declare function switchToWindow(windowIndex: number): void;
|
|
27
33
|
export declare function listYeehawWindows(): TmuxWindow[];
|
|
28
34
|
export declare function killWindow(windowIndex: number): void;
|
|
29
35
|
/**
|
|
30
|
-
* Get formatted status
|
|
36
|
+
* Get formatted status info for a tmux window
|
|
31
37
|
*/
|
|
32
|
-
export declare function getWindowStatus(window: TmuxWindow):
|
|
38
|
+
export declare function getWindowStatus(window: TmuxWindow): WindowStatusInfo;
|
|
33
39
|
export declare function updateStatusBar(projectName?: string): void;
|
|
34
40
|
export declare function enterRemoteMode(barnName: string, host: string, user: string, port: number, identityFile: string): number;
|
|
35
41
|
export declare function exitRemoteMode(): void;
|