gru-ai 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/.claude/skills/brainstorm/SKILL.md +340 -0
- package/.claude/skills/code-review-excellence/SKILL.md +198 -0
- package/.claude/skills/directive/SKILL.md +121 -0
- package/.claude/skills/directive/docs/pipeline/00-delegation-and-triage.md +181 -0
- package/.claude/skills/directive/docs/pipeline/01-checkpoint.md +34 -0
- package/.claude/skills/directive/docs/pipeline/02-read-directive.md +38 -0
- package/.claude/skills/directive/docs/pipeline/03-read-context.md +15 -0
- package/.claude/skills/directive/docs/pipeline/04-challenge.md +38 -0
- package/.claude/skills/directive/docs/pipeline/05-planning.md +64 -0
- package/.claude/skills/directive/docs/pipeline/06-technical-audit.md +88 -0
- package/.claude/skills/directive/docs/pipeline/07-plan-approval.md +145 -0
- package/.claude/skills/directive/docs/pipeline/07b-project-brainstorm.md +85 -0
- package/.claude/skills/directive/docs/pipeline/08-worktree-and-state.md +50 -0
- package/.claude/skills/directive/docs/pipeline/09-execute-projects.md +709 -0
- package/.claude/skills/directive/docs/pipeline/10-wrapup.md +242 -0
- package/.claude/skills/directive/docs/pipeline/11-completion-gate.md +75 -0
- package/.claude/skills/directive/docs/reference/rules/casting-rules.md +78 -0
- package/.claude/skills/directive/docs/reference/rules/failure-handling.md +20 -0
- package/.claude/skills/directive/docs/reference/rules/phase-definitions.md +42 -0
- package/.claude/skills/directive/docs/reference/rules/scope-and-dod.md +30 -0
- package/.claude/skills/directive/docs/reference/schemas/audit-output.md +44 -0
- package/.claude/skills/directive/docs/reference/schemas/brainstorm-output.md +52 -0
- package/.claude/skills/directive/docs/reference/schemas/challenger-output.md +13 -0
- package/.claude/skills/directive/docs/reference/schemas/checkpoint.md +18 -0
- package/.claude/skills/directive/docs/reference/schemas/current-json.md +5 -0
- package/.claude/skills/directive/docs/reference/schemas/directive-json.md +143 -0
- package/.claude/skills/directive/docs/reference/schemas/investigation-output.md +37 -0
- package/.claude/skills/directive/docs/reference/schemas/plan-schema.md +103 -0
- package/.claude/skills/directive/docs/reference/templates/architect-prompt.md +66 -0
- package/.claude/skills/directive/docs/reference/templates/auditor-prompt.md +53 -0
- package/.claude/skills/directive/docs/reference/templates/brainstorm-prompt.md +68 -0
- package/.claude/skills/directive/docs/reference/templates/challenger-prompt.md +35 -0
- package/.claude/skills/directive/docs/reference/templates/digest.md +134 -0
- package/.claude/skills/directive/docs/reference/templates/investigator-prompt.md +51 -0
- package/.claude/skills/directive/docs/reference/templates/planner-prompt.md +130 -0
- package/.claude/skills/frontend-design/SKILL.md +42 -0
- package/.claude/skills/gruai-agents/SKILL.md +161 -0
- package/.claude/skills/gruai-config/SKILL.md +61 -0
- package/.claude/skills/healthcheck/SKILL.md +216 -0
- package/.claude/skills/report/SKILL.md +380 -0
- package/.claude/skills/scout/SKILL.md +452 -0
- package/.claude/skills/seo-audit/SKILL.md +107 -0
- package/.claude/skills/walkthrough/SKILL.md +274 -0
- package/.claude/skills/webapp-testing/SKILL.md +96 -0
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/cli/templates/CLAUDE.md.template +57 -0
- package/cli/templates/agent-roles/backend.md +47 -0
- package/cli/templates/agent-roles/cmo.md +52 -0
- package/cli/templates/agent-roles/content.md +48 -0
- package/cli/templates/agent-roles/coo.md +66 -0
- package/cli/templates/agent-roles/cpo.md +52 -0
- package/cli/templates/agent-roles/cto.md +63 -0
- package/cli/templates/agent-roles/data.md +46 -0
- package/cli/templates/agent-roles/design.md +46 -0
- package/cli/templates/agent-roles/frontend.md +47 -0
- package/cli/templates/agent-roles/fullstack.md +47 -0
- package/cli/templates/agent-roles/qa.md +46 -0
- package/cli/templates/backlog.json.template +3 -0
- package/cli/templates/directive.json.template +9 -0
- package/cli/templates/directive.md.template +23 -0
- package/cli/templates/goals-index.md +21 -0
- package/cli/templates/gruai.config.json.template +12 -0
- package/cli/templates/lessons.md +16 -0
- package/cli/templates/vision.md +35 -0
- package/cli/templates/welcome-directive/directive.json +9 -0
- package/cli/templates/welcome-directive/directive.md +53 -0
- package/dist/assets/GamePage-C5XQQOQH.js +49 -0
- package/dist/assets/README.md +17 -0
- package/dist/assets/characters/char_0.png +0 -0
- package/dist/assets/characters/char_1.png +0 -0
- package/dist/assets/characters/char_10.png +0 -0
- package/dist/assets/characters/char_11.png +0 -0
- package/dist/assets/characters/char_2.png +0 -0
- package/dist/assets/characters/char_3.png +0 -0
- package/dist/assets/characters/char_4.png +0 -0
- package/dist/assets/characters/char_5.png +0 -0
- package/dist/assets/characters/char_6.png +0 -0
- package/dist/assets/characters/char_7.png +0 -0
- package/dist/assets/characters/char_8.png +0 -0
- package/dist/assets/characters/char_9.png +0 -0
- package/dist/assets/index-CnTPDqpP.js +12 -0
- package/dist/assets/index-gR5q7ikB.css +1 -0
- package/dist/assets/office/furniture.png +0 -0
- package/dist/assets/office/room-builder.png +0 -0
- package/dist/index.html +16 -0
- package/dist-server/scripts/intelligence-trends.d.ts +100 -0
- package/dist-server/scripts/intelligence-trends.js +365 -0
- package/dist-server/server/actions/cleanup.d.ts +4 -0
- package/dist-server/server/actions/cleanup.js +30 -0
- package/dist-server/server/actions/send-input.d.ts +6 -0
- package/dist-server/server/actions/send-input.js +147 -0
- package/dist-server/server/actions/terminal.d.ts +4 -0
- package/dist-server/server/actions/terminal.js +427 -0
- package/dist-server/server/config.d.ts +9 -0
- package/dist-server/server/config.js +217 -0
- package/dist-server/server/db.d.ts +7 -0
- package/dist-server/server/db.js +79 -0
- package/dist-server/server/hooks/event-receiver.d.ts +11 -0
- package/dist-server/server/hooks/event-receiver.js +36 -0
- package/dist-server/server/index.d.ts +1 -0
- package/dist-server/server/index.js +552 -0
- package/dist-server/server/notifications/macos.d.ts +5 -0
- package/dist-server/server/notifications/macos.js +22 -0
- package/dist-server/server/notifications/notifier.d.ts +17 -0
- package/dist-server/server/notifications/notifier.js +110 -0
- package/dist-server/server/parsers/process-discovery.d.ts +39 -0
- package/dist-server/server/parsers/process-discovery.js +776 -0
- package/dist-server/server/parsers/session-scanner.d.ts +56 -0
- package/dist-server/server/parsers/session-scanner.js +390 -0
- package/dist-server/server/parsers/session-state.d.ts +68 -0
- package/dist-server/server/parsers/session-state.js +696 -0
- package/dist-server/server/parsers/session-state.test.d.ts +1 -0
- package/dist-server/server/parsers/session-state.test.js +950 -0
- package/dist-server/server/parsers/task-parser.d.ts +10 -0
- package/dist-server/server/parsers/task-parser.js +97 -0
- package/dist-server/server/parsers/team-parser.d.ts +3 -0
- package/dist-server/server/parsers/team-parser.js +67 -0
- package/dist-server/server/platform/__tests__/claude-code.test.d.ts +1 -0
- package/dist-server/server/platform/__tests__/claude-code.test.js +311 -0
- package/dist-server/server/platform/claude-code.d.ts +34 -0
- package/dist-server/server/platform/claude-code.js +94 -0
- package/dist-server/server/platform/index.d.ts +5 -0
- package/dist-server/server/platform/index.js +1 -0
- package/dist-server/server/platform/types.d.ts +190 -0
- package/dist-server/server/platform/types.js +9 -0
- package/dist-server/server/state/aggregator.d.ts +42 -0
- package/dist-server/server/state/aggregator.js +1080 -0
- package/dist-server/server/state/work-item-types.d.ts +555 -0
- package/dist-server/server/state/work-item-types.js +168 -0
- package/dist-server/server/types.d.ts +237 -0
- package/dist-server/server/types.js +1 -0
- package/dist-server/server/watchers/claude-watcher.d.ts +17 -0
- package/dist-server/server/watchers/claude-watcher.js +130 -0
- package/dist-server/server/watchers/context-watcher.d.ts +22 -0
- package/dist-server/server/watchers/context-watcher.js +125 -0
- package/dist-server/server/watchers/directive-watcher.d.ts +46 -0
- package/dist-server/server/watchers/directive-watcher.js +497 -0
- package/dist-server/server/watchers/session-watcher.d.ts +18 -0
- package/dist-server/server/watchers/session-watcher.js +126 -0
- package/dist-server/server/watchers/state-watcher.d.ts +36 -0
- package/dist-server/server/watchers/state-watcher.js +369 -0
- package/package.json +68 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
const TMUX_PANE_ID_REGEX = /^%\d+$/;
|
|
5
|
+
function escapeAppleScript(str) {
|
|
6
|
+
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
7
|
+
}
|
|
8
|
+
function validateOwnership(paneId, aggregator) {
|
|
9
|
+
const state = aggregator.getState();
|
|
10
|
+
const ownedBySession = state.sessions.some((s) => s.paneId === paneId);
|
|
11
|
+
const ownedByMember = state.teams.some((team) => team.members.some((member) => member.tmuxPaneId === paneId));
|
|
12
|
+
return ownedBySession || ownedByMember;
|
|
13
|
+
}
|
|
14
|
+
async function sendInputTmux(paneId, input, type) {
|
|
15
|
+
// Verify pane exists in tmux
|
|
16
|
+
try {
|
|
17
|
+
const { stdout: allPanes } = await execFileAsync('tmux', [
|
|
18
|
+
'list-panes', '-a', '-F', '#{pane_id}',
|
|
19
|
+
]);
|
|
20
|
+
const paneIds = allPanes.trim().split('\n');
|
|
21
|
+
if (!paneIds.includes(paneId)) {
|
|
22
|
+
return { ok: false, error: 'Pane not found' };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return { ok: false, error: 'Pane not found' };
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
switch (type) {
|
|
30
|
+
case 'approve':
|
|
31
|
+
await execFileAsync('tmux', ['send-keys', '-t', paneId, '-l', 'y']);
|
|
32
|
+
await execFileAsync('tmux', ['send-keys', '-t', paneId, 'Enter']);
|
|
33
|
+
break;
|
|
34
|
+
case 'reject':
|
|
35
|
+
await execFileAsync('tmux', ['send-keys', '-t', paneId, '-l', 'n']);
|
|
36
|
+
await execFileAsync('tmux', ['send-keys', '-t', paneId, 'Enter']);
|
|
37
|
+
break;
|
|
38
|
+
case 'abort':
|
|
39
|
+
await execFileAsync('tmux', ['send-keys', '-t', paneId, 'C-c']);
|
|
40
|
+
break;
|
|
41
|
+
case 'text':
|
|
42
|
+
await execFileAsync('tmux', ['send-keys', '-t', paneId, '-l', input]);
|
|
43
|
+
await execFileAsync('tmux', ['send-keys', '-t', paneId, 'Enter']);
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
return { ok: true };
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
50
|
+
return { ok: false, error: message };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function sendInputIterm(paneId, input, type) {
|
|
54
|
+
const itermId = paneId.slice('iterm:'.length);
|
|
55
|
+
// Sanitize: iTerm2 unique IDs are alphanumeric with hyphens/dots
|
|
56
|
+
const safeId = itermId.replace(/[^a-zA-Z0-9\-_.]/g, '');
|
|
57
|
+
if (!safeId || safeId !== itermId) {
|
|
58
|
+
return { ok: false, error: `Invalid iTerm session ID: ${itermId}` };
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
if (type === 'abort') {
|
|
62
|
+
// Send Ctrl-C (ASCII character 3) without newline
|
|
63
|
+
const script = `
|
|
64
|
+
tell application "iTerm2"
|
|
65
|
+
repeat with w in windows
|
|
66
|
+
repeat with t in tabs of w
|
|
67
|
+
repeat with s in sessions of t
|
|
68
|
+
if unique ID of s is "${safeId}" then
|
|
69
|
+
tell s to write text (ASCII character 3) without newline
|
|
70
|
+
return "ok"
|
|
71
|
+
end if
|
|
72
|
+
end repeat
|
|
73
|
+
end repeat
|
|
74
|
+
end repeat
|
|
75
|
+
return "not_found"
|
|
76
|
+
end tell
|
|
77
|
+
`;
|
|
78
|
+
const { stdout } = await execFileAsync('osascript', ['-e', script], { timeout: 5000 });
|
|
79
|
+
if (stdout.trim() === 'not_found') {
|
|
80
|
+
return { ok: false, error: 'iTerm2 session not found' };
|
|
81
|
+
}
|
|
82
|
+
return { ok: true };
|
|
83
|
+
}
|
|
84
|
+
// For approve/reject/text: write text sends with newline by default
|
|
85
|
+
let textToSend;
|
|
86
|
+
switch (type) {
|
|
87
|
+
case 'approve':
|
|
88
|
+
textToSend = 'y';
|
|
89
|
+
break;
|
|
90
|
+
case 'reject':
|
|
91
|
+
textToSend = 'n';
|
|
92
|
+
break;
|
|
93
|
+
case 'text':
|
|
94
|
+
textToSend = input;
|
|
95
|
+
break;
|
|
96
|
+
default:
|
|
97
|
+
return { ok: false, error: `Unknown input type: ${type}` };
|
|
98
|
+
}
|
|
99
|
+
const escaped = escapeAppleScript(textToSend);
|
|
100
|
+
const script = `
|
|
101
|
+
tell application "iTerm2"
|
|
102
|
+
repeat with w in windows
|
|
103
|
+
repeat with t in tabs of w
|
|
104
|
+
repeat with s in sessions of t
|
|
105
|
+
if unique ID of s is "${safeId}" then
|
|
106
|
+
tell s to write text "${escaped}"
|
|
107
|
+
return "ok"
|
|
108
|
+
end if
|
|
109
|
+
end repeat
|
|
110
|
+
end repeat
|
|
111
|
+
end repeat
|
|
112
|
+
return "not_found"
|
|
113
|
+
end tell
|
|
114
|
+
`;
|
|
115
|
+
const { stdout } = await execFileAsync('osascript', ['-e', script], { timeout: 5000 });
|
|
116
|
+
if (stdout.trim() === 'not_found') {
|
|
117
|
+
return { ok: false, error: 'iTerm2 session not found' };
|
|
118
|
+
}
|
|
119
|
+
return { ok: true };
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
123
|
+
return { ok: false, error: message };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
export async function sendInput(request, aggregator) {
|
|
127
|
+
const { paneId, input, type } = request;
|
|
128
|
+
// Validate ownership
|
|
129
|
+
if (!validateOwnership(paneId, aggregator)) {
|
|
130
|
+
return { ok: false, error: 'Pane not owned by any known session' };
|
|
131
|
+
}
|
|
132
|
+
// Validate text input length
|
|
133
|
+
if (type === 'text' && input.length > 500) {
|
|
134
|
+
return { ok: false, error: 'Input too long' };
|
|
135
|
+
}
|
|
136
|
+
// Route to the appropriate handler
|
|
137
|
+
if (paneId.startsWith('iterm:')) {
|
|
138
|
+
return sendInputIterm(paneId, input, type);
|
|
139
|
+
}
|
|
140
|
+
if (TMUX_PANE_ID_REGEX.test(paneId)) {
|
|
141
|
+
return sendInputTmux(paneId, input, type);
|
|
142
|
+
}
|
|
143
|
+
if (paneId.startsWith('warp:') || paneId.startsWith('terminal:')) {
|
|
144
|
+
return { ok: false, error: 'Send-input not supported for this terminal (use tmux for full support)' };
|
|
145
|
+
}
|
|
146
|
+
return { ok: false, error: 'Invalid pane ID format' };
|
|
147
|
+
}
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
const PANE_ID_REGEX = /^%\d+$/;
|
|
5
|
+
/**
|
|
6
|
+
* Extract the numeric suffix from a tty path like "/dev/ttys060" → 60.
|
|
7
|
+
*/
|
|
8
|
+
function ttyNumber(tty) {
|
|
9
|
+
const m = tty.match(/ttys(\d+)$/);
|
|
10
|
+
return m ? parseInt(m[1], 10) : null;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Detect which terminal application hosts a given tty by walking the process tree.
|
|
14
|
+
*/
|
|
15
|
+
async function detectTerminalForTty(tty) {
|
|
16
|
+
try {
|
|
17
|
+
// Get the full process tree in one call
|
|
18
|
+
const { stdout: allProcs } = await execFileAsync('ps', ['-axo', 'pid=,ppid=,comm=']);
|
|
19
|
+
const procs = new Map();
|
|
20
|
+
for (const line of allProcs.trim().split('\n')) {
|
|
21
|
+
const match = line.trim().match(/^\s*(\d+)\s+(\d+)\s+(.+)$/);
|
|
22
|
+
if (match) {
|
|
23
|
+
procs.set(parseInt(match[1]), { ppid: parseInt(match[2]), comm: match[3].trim() });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// Find processes on this tty
|
|
27
|
+
const ttyShort = tty.replace('/dev/', '');
|
|
28
|
+
const { stdout: ttyOutput } = await execFileAsync('ps', ['-t', ttyShort, '-o', 'pid=']);
|
|
29
|
+
const ttyPids = ttyOutput.trim().split('\n')
|
|
30
|
+
.map(s => parseInt(s.trim()))
|
|
31
|
+
.filter(n => !isNaN(n));
|
|
32
|
+
// Walk up the process tree from each tty process
|
|
33
|
+
for (const startPid of ttyPids) {
|
|
34
|
+
let current = startPid;
|
|
35
|
+
const visited = new Set();
|
|
36
|
+
while (current > 1 && !visited.has(current)) {
|
|
37
|
+
visited.add(current);
|
|
38
|
+
const proc = procs.get(current);
|
|
39
|
+
if (!proc)
|
|
40
|
+
break;
|
|
41
|
+
const comm = proc.comm;
|
|
42
|
+
if (/iTerm/i.test(comm))
|
|
43
|
+
return 'iterm2';
|
|
44
|
+
if (/[Ww]arp/.test(comm))
|
|
45
|
+
return 'warp';
|
|
46
|
+
if (/^Terminal$/.test(comm))
|
|
47
|
+
return 'terminal';
|
|
48
|
+
current = proc.ppid;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return 'unknown';
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return 'unknown';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Bring iTerm2 to foreground, bypassing macOS focus-stealing prevention.
|
|
59
|
+
*/
|
|
60
|
+
async function bringITermToFront() {
|
|
61
|
+
try {
|
|
62
|
+
await execFileAsync('osascript', ['-l', 'JavaScript', '-e', `
|
|
63
|
+
ObjC.import('AppKit');
|
|
64
|
+
var apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.googlecode.iterm2');
|
|
65
|
+
if (apps.count > 0) {
|
|
66
|
+
apps.objectAtIndex(0).activateWithOptions(3);
|
|
67
|
+
'ok';
|
|
68
|
+
}
|
|
69
|
+
`]);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Best effort
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Activate iTerm2 and focus the specific tab containing a tmux client tty.
|
|
77
|
+
*
|
|
78
|
+
* iTerm2 tab ttys differ from tmux client ttys by 1 (the tmux client is a
|
|
79
|
+
* child process of the shell in the iTerm2 tab). So we enumerate all iTerm2
|
|
80
|
+
* tabs, find the one whose tty number is 1 less than the tmux client tty,
|
|
81
|
+
* and select that tab.
|
|
82
|
+
*/
|
|
83
|
+
async function activateITermTab(tmuxClientTty) {
|
|
84
|
+
const targetNum = ttyNumber(tmuxClientTty);
|
|
85
|
+
if (targetNum !== null) {
|
|
86
|
+
// Enumerate iTerm2 windows/tabs/ttys and select the matching one
|
|
87
|
+
const script = `
|
|
88
|
+
tell application "iTerm2"
|
|
89
|
+
repeat with w in windows
|
|
90
|
+
set tabIdx to 0
|
|
91
|
+
repeat with t in tabs of w
|
|
92
|
+
set tabIdx to tabIdx + 1
|
|
93
|
+
repeat with s in sessions of t
|
|
94
|
+
set sessionTty to tty of s
|
|
95
|
+
if sessionTty contains "ttys" then
|
|
96
|
+
set ttyNum to text ((offset of "ttys" in sessionTty) + 4) thru -1 of sessionTty
|
|
97
|
+
try
|
|
98
|
+
set ttyNum to ttyNum as integer
|
|
99
|
+
if ttyNum = ${targetNum - 1} then
|
|
100
|
+
select t
|
|
101
|
+
set index of w to 1
|
|
102
|
+
return "found"
|
|
103
|
+
end if
|
|
104
|
+
end try
|
|
105
|
+
end if
|
|
106
|
+
end repeat
|
|
107
|
+
end repeat
|
|
108
|
+
end repeat
|
|
109
|
+
return "not_found"
|
|
110
|
+
end tell
|
|
111
|
+
`;
|
|
112
|
+
try {
|
|
113
|
+
const { stdout } = await execFileAsync('osascript', ['-e', script]);
|
|
114
|
+
if (stdout.trim() === 'found') {
|
|
115
|
+
await bringITermToFront();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Fall through to fallback
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Fallback: try exact tty match (in case the off-by-1 assumption doesn't hold)
|
|
124
|
+
const fallbackScript = `
|
|
125
|
+
tell application "iTerm2"
|
|
126
|
+
repeat with w in windows
|
|
127
|
+
repeat with t in tabs of w
|
|
128
|
+
repeat with s in sessions of t
|
|
129
|
+
if tty of s = "${tmuxClientTty}" then
|
|
130
|
+
select t
|
|
131
|
+
set index of w to 1
|
|
132
|
+
return "found"
|
|
133
|
+
end if
|
|
134
|
+
end repeat
|
|
135
|
+
end repeat
|
|
136
|
+
end repeat
|
|
137
|
+
end tell
|
|
138
|
+
`;
|
|
139
|
+
try {
|
|
140
|
+
await execFileAsync('osascript', ['-e', fallbackScript]);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Best effort
|
|
144
|
+
}
|
|
145
|
+
await bringITermToFront();
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Bring Warp to foreground and optionally switch to a specific tab.
|
|
149
|
+
*
|
|
150
|
+
* Warp has no AppleScript dictionary, so we use CGEvents to send Cmd+N keystrokes.
|
|
151
|
+
* Tab order is determined by sorting each tab's login shell PID start time.
|
|
152
|
+
*/
|
|
153
|
+
async function activateWarp(tabIndex) {
|
|
154
|
+
// macOS keycodes for digits 1-9
|
|
155
|
+
const DIGIT_KEYCODES = {
|
|
156
|
+
1: 18, 2: 19, 3: 20, 4: 21, 5: 23, 6: 22, 7: 26, 8: 28, 9: 25,
|
|
157
|
+
};
|
|
158
|
+
const keycode = tabIndex ? DIGIT_KEYCODES[tabIndex] : undefined;
|
|
159
|
+
try {
|
|
160
|
+
if (keycode) {
|
|
161
|
+
// Activate Warp + send Cmd+N keystroke to switch tab
|
|
162
|
+
await execFileAsync('osascript', ['-l', 'JavaScript', '-e', `
|
|
163
|
+
ObjC.import('AppKit');
|
|
164
|
+
ObjC.import('CoreGraphics');
|
|
165
|
+
var apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier('dev.warp.Warp-Stable');
|
|
166
|
+
if (apps.count > 0) {
|
|
167
|
+
apps.objectAtIndex(0).activateWithOptions(3);
|
|
168
|
+
}
|
|
169
|
+
delay(0.3);
|
|
170
|
+
var src = $.CGEventSourceCreate($.kCGEventSourceStateHIDSystemState);
|
|
171
|
+
var keyDown = $.CGEventCreateKeyboardEvent(src, ${keycode}, true);
|
|
172
|
+
$.CGEventSetFlags(keyDown, $.kCGEventFlagMaskCommand);
|
|
173
|
+
var keyUp = $.CGEventCreateKeyboardEvent(src, ${keycode}, false);
|
|
174
|
+
$.CGEventSetFlags(keyUp, $.kCGEventFlagMaskCommand);
|
|
175
|
+
$.CGEventPost($.kCGHIDEventTap, keyDown);
|
|
176
|
+
$.CGEventPost($.kCGHIDEventTap, keyUp);
|
|
177
|
+
'ok';
|
|
178
|
+
`], { timeout: 5000 });
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
// Just bring Warp to front without tab switching
|
|
182
|
+
await execFileAsync('osascript', ['-l', 'JavaScript', '-e', `
|
|
183
|
+
ObjC.import('AppKit');
|
|
184
|
+
var apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier('dev.warp.Warp-Stable');
|
|
185
|
+
if (apps.count > 0) {
|
|
186
|
+
apps.objectAtIndex(0).activateWithOptions(3);
|
|
187
|
+
'ok';
|
|
188
|
+
}
|
|
189
|
+
`]);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// Best effort
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Find which Warp tab a given PID belongs to by mapping TTYs to tab order.
|
|
198
|
+
*
|
|
199
|
+
* Returns a 1-based tab index (1-9), or undefined if we can't determine it.
|
|
200
|
+
*/
|
|
201
|
+
async function findWarpTabIndex(targetPid) {
|
|
202
|
+
try {
|
|
203
|
+
// 1. Get the target process's TTY
|
|
204
|
+
const { stdout: ttyOut } = await execFileAsync('ps', ['-o', 'tty=', '-p', String(targetPid)]);
|
|
205
|
+
const targetTty = ttyOut.trim();
|
|
206
|
+
if (!targetTty || targetTty === '??')
|
|
207
|
+
return undefined;
|
|
208
|
+
// 2. Build process tree to find all TTYs owned by Warp
|
|
209
|
+
const { stdout: allProcs } = await execFileAsync('ps', ['-axo', 'pid=,ppid=,tty=,lstart=,comm=']);
|
|
210
|
+
const procs = new Map();
|
|
211
|
+
for (const line of allProcs.trim().split('\n')) {
|
|
212
|
+
// Format: PID PPID TTY LSTART(5 fields) COMM
|
|
213
|
+
const m = line.trim().match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s+(\w+\s+\d+\s+\w+\s+[\d:]+\s+\d+)\s+(.+)$/);
|
|
214
|
+
if (m) {
|
|
215
|
+
procs.set(parseInt(m[1]), {
|
|
216
|
+
ppid: parseInt(m[2]),
|
|
217
|
+
tty: m[3],
|
|
218
|
+
lstart: m[4],
|
|
219
|
+
comm: m[5].trim(),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// 3. Find Warp's main PID (the process named "stable" under Warp.app)
|
|
224
|
+
let warpPid;
|
|
225
|
+
for (const [pid, info] of procs) {
|
|
226
|
+
if (info.comm.includes('Warp.app') && info.comm.includes('/stable')) {
|
|
227
|
+
// Pick the one whose parent is NOT another Warp process
|
|
228
|
+
const parent = procs.get(info.ppid);
|
|
229
|
+
if (!parent || !parent.comm.includes('Warp.app')) {
|
|
230
|
+
warpPid = pid;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (!warpPid)
|
|
236
|
+
return undefined;
|
|
237
|
+
// 4. Find all login shells descended from Warp — these are one per tab
|
|
238
|
+
// Walk ancestors of each process with a real TTY to check if Warp is in the chain
|
|
239
|
+
const ttyToStartTime = new Map();
|
|
240
|
+
for (const [pid, info] of procs) {
|
|
241
|
+
if (info.tty === '??' || ttyToStartTime.has(info.tty))
|
|
242
|
+
continue;
|
|
243
|
+
// Only consider login shells (the first process in each tab)
|
|
244
|
+
if (!(info.comm === '-zsh' || info.comm === '-bash' || info.comm === '-fish' || info.comm === 'login'))
|
|
245
|
+
continue;
|
|
246
|
+
// Check if this process descends from Warp
|
|
247
|
+
let current = info.ppid;
|
|
248
|
+
const visited = new Set();
|
|
249
|
+
let isWarpChild = false;
|
|
250
|
+
while (current > 1 && !visited.has(current)) {
|
|
251
|
+
visited.add(current);
|
|
252
|
+
if (current === warpPid) {
|
|
253
|
+
isWarpChild = true;
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
const p = procs.get(current);
|
|
257
|
+
if (!p)
|
|
258
|
+
break;
|
|
259
|
+
current = p.ppid;
|
|
260
|
+
}
|
|
261
|
+
if (isWarpChild) {
|
|
262
|
+
ttyToStartTime.set(info.tty, pid); // Use PID as proxy for creation order (lower PID = earlier)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (!ttyToStartTime.has(targetTty))
|
|
266
|
+
return undefined;
|
|
267
|
+
// 5. Sort TTYs by their login shell PID (ascending = creation order = tab order)
|
|
268
|
+
const sortedTtys = [...ttyToStartTime.entries()]
|
|
269
|
+
.sort((a, b) => a[1] - b[1])
|
|
270
|
+
.map(e => e[0]);
|
|
271
|
+
const tabIndex = sortedTtys.indexOf(targetTty) + 1; // 1-based
|
|
272
|
+
return (tabIndex >= 1 && tabIndex <= 9) ? tabIndex : undefined;
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Bring Terminal.app to foreground.
|
|
280
|
+
*/
|
|
281
|
+
async function activateTerminalApp() {
|
|
282
|
+
try {
|
|
283
|
+
await execFileAsync('osascript', ['-l', 'JavaScript', '-e', `
|
|
284
|
+
ObjC.import('AppKit');
|
|
285
|
+
var apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier('com.apple.Terminal');
|
|
286
|
+
if (apps.count > 0) {
|
|
287
|
+
apps.objectAtIndex(0).activateWithOptions(3);
|
|
288
|
+
'ok';
|
|
289
|
+
}
|
|
290
|
+
`]);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// Best effort
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Focus an iTerm2 session directly by its unique ID (non-tmux).
|
|
298
|
+
*/
|
|
299
|
+
async function focusItermSession(itermId) {
|
|
300
|
+
// Sanitize: iTerm2 unique IDs are alphanumeric with hyphens/dots
|
|
301
|
+
const safeId = itermId.replace(/[^a-zA-Z0-9\-_.]/g, '');
|
|
302
|
+
if (!safeId || safeId !== itermId) {
|
|
303
|
+
return { ok: false, error: `Invalid iTerm session ID: ${itermId}` };
|
|
304
|
+
}
|
|
305
|
+
const script = `
|
|
306
|
+
tell application "iTerm2"
|
|
307
|
+
repeat with w in windows
|
|
308
|
+
repeat with t in tabs of w
|
|
309
|
+
repeat with s in sessions of t
|
|
310
|
+
if unique ID of s is "${safeId}" then
|
|
311
|
+
select t
|
|
312
|
+
set index of w to 1
|
|
313
|
+
return "found"
|
|
314
|
+
end if
|
|
315
|
+
end repeat
|
|
316
|
+
end repeat
|
|
317
|
+
end repeat
|
|
318
|
+
return "not_found"
|
|
319
|
+
end tell
|
|
320
|
+
`;
|
|
321
|
+
try {
|
|
322
|
+
const { stdout } = await execFileAsync('osascript', ['-e', script], { timeout: 5000 });
|
|
323
|
+
if (stdout.trim() === 'found') {
|
|
324
|
+
await bringITermToFront();
|
|
325
|
+
return { ok: true };
|
|
326
|
+
}
|
|
327
|
+
return { ok: false, error: 'iTerm2 session not found' };
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
331
|
+
return { ok: false, error: message };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
export async function focusPane(paneId) {
|
|
335
|
+
// Route iTerm2 native sessions (non-tmux)
|
|
336
|
+
if (paneId.startsWith('iterm:')) {
|
|
337
|
+
const itermId = paneId.slice('iterm:'.length);
|
|
338
|
+
return focusItermSession(itermId);
|
|
339
|
+
}
|
|
340
|
+
// Route Warp native sessions — activate and switch to the correct tab
|
|
341
|
+
if (paneId.startsWith('warp:')) {
|
|
342
|
+
const pid = parseInt(paneId.slice('warp:'.length), 10);
|
|
343
|
+
const tabIndex = isNaN(pid) ? undefined : await findWarpTabIndex(pid);
|
|
344
|
+
await activateWarp(tabIndex);
|
|
345
|
+
return { ok: true };
|
|
346
|
+
}
|
|
347
|
+
// Route Terminal.app native sessions — best-effort bring to front
|
|
348
|
+
if (paneId.startsWith('terminal:')) {
|
|
349
|
+
await activateTerminalApp();
|
|
350
|
+
return { ok: true };
|
|
351
|
+
}
|
|
352
|
+
if (!PANE_ID_REGEX.test(paneId)) {
|
|
353
|
+
return { ok: false, error: `Invalid pane ID format: ${paneId}` };
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
// Check that the pane exists and get its session + window info
|
|
357
|
+
const { stdout: paneInfo } = await execFileAsync('tmux', [
|
|
358
|
+
'display-message', '-p', '-t', paneId,
|
|
359
|
+
'#{session_name}:#{window_id}\t#{pane_id}',
|
|
360
|
+
]);
|
|
361
|
+
if (!paneInfo.trim().includes(paneId)) {
|
|
362
|
+
return { ok: false, error: `Pane ${paneId} not found` };
|
|
363
|
+
}
|
|
364
|
+
// Get the window ID for tmux switching
|
|
365
|
+
const { stdout: windowId } = await execFileAsync('tmux', [
|
|
366
|
+
'display-message', '-p', '-t', paneId, '#{window_id}',
|
|
367
|
+
]);
|
|
368
|
+
const trimmedWindowId = windowId.trim();
|
|
369
|
+
// Get the tmux session name
|
|
370
|
+
const { stdout: sessionName } = await execFileAsync('tmux', [
|
|
371
|
+
'display-message', '-p', '-t', paneId, '#{session_name}',
|
|
372
|
+
]);
|
|
373
|
+
// Find the client tty attached to this tmux session
|
|
374
|
+
const { stdout: clients } = await execFileAsync('tmux', [
|
|
375
|
+
'list-clients', '-F', '#{client_tty}\t#{client_session}',
|
|
376
|
+
]);
|
|
377
|
+
let clientTty;
|
|
378
|
+
for (const line of clients.trim().split('\n')) {
|
|
379
|
+
const [tty, sess] = line.split('\t');
|
|
380
|
+
if (sess === sessionName.trim()) {
|
|
381
|
+
clientTty = tty;
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// Select the tmux window and pane
|
|
386
|
+
await execFileAsync('tmux', ['select-window', '-t', trimmedWindowId]);
|
|
387
|
+
await execFileAsync('tmux', ['select-pane', '-t', paneId]);
|
|
388
|
+
// Detect which terminal hosts the tmux client and activate it
|
|
389
|
+
if (clientTty) {
|
|
390
|
+
const terminal = await detectTerminalForTty(clientTty);
|
|
391
|
+
switch (terminal) {
|
|
392
|
+
case 'iterm2':
|
|
393
|
+
await activateITermTab(clientTty);
|
|
394
|
+
break;
|
|
395
|
+
case 'warp': {
|
|
396
|
+
// For tmux+Warp, find the tab by looking up processes on the client TTY
|
|
397
|
+
const { stdout: clientPidOut } = await execFileAsync('ps', ['-t', clientTty.replace('/dev/', ''), '-o', 'pid=']);
|
|
398
|
+
const clientPids = clientPidOut.trim().split('\n').map(s => parseInt(s.trim())).filter(n => !isNaN(n));
|
|
399
|
+
let warpTabIdx;
|
|
400
|
+
for (const cp of clientPids) {
|
|
401
|
+
warpTabIdx = await findWarpTabIndex(cp);
|
|
402
|
+
if (warpTabIdx)
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
await activateWarp(warpTabIdx);
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
case 'terminal':
|
|
409
|
+
await activateTerminalApp();
|
|
410
|
+
break;
|
|
411
|
+
default:
|
|
412
|
+
// Detection failed — try iTerm2 tab matching first (most common), then generic
|
|
413
|
+
await activateITermTab(clientTty);
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
// No attached client — best-effort bring any terminal to front
|
|
419
|
+
await bringITermToFront();
|
|
420
|
+
}
|
|
421
|
+
return { ok: true };
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
425
|
+
return { ok: false, error: message };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ConductorConfig, ProjectConfig } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Discover conductor-enabled projects from ~/.claude/projects/.
|
|
4
|
+
* Scans subdirectories, decodes their names to real paths, and checks
|
|
5
|
+
* for `.context/` to identify conductor-enabled repos.
|
|
6
|
+
*/
|
|
7
|
+
export declare function discoverProjects(claudeHome: string): ProjectConfig[];
|
|
8
|
+
export declare function loadConfig(): ConductorConfig;
|
|
9
|
+
export declare function saveConfig(config: ConductorConfig): void;
|