@dinhtungdu/watcher 1.0.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/CONTEXT.md +77 -0
- package/README.md +20 -0
- package/docs/adr/0001-agent-switcher-architecture.md +15 -0
- package/docs/adr/0002-agent-switcher-focus-layout.md +15 -0
- package/docs/adr/0003-terminal-backends.md +13 -0
- package/docs/adr/0004-agent-integrations-and-events.md +40 -0
- package/docs/assets/watcher-terminal-preview.png +0 -0
- package/docs/requirements.md +189 -0
- package/package.json +40 -0
- package/src/activation.ts +58 -0
- package/src/agentEventReducer.ts +336 -0
- package/src/agentEvents.ts +188 -0
- package/src/agents/claude.ts +16 -0
- package/src/agents/codex.ts +16 -0
- package/src/agents/opencode.ts +16 -0
- package/src/agents/pi.ts +16 -0
- package/src/agents/registry.ts +54 -0
- package/src/agents/types.ts +28 -0
- package/src/ansiShell.ts +104 -0
- package/src/cli.ts +73 -0
- package/src/daemon.ts +101 -0
- package/src/discovery.ts +85 -0
- package/src/eventCommand.ts +114 -0
- package/src/git.ts +28 -0
- package/src/integrationsInstaller.ts +218 -0
- package/src/ipc.ts +56 -0
- package/src/model.ts +94 -0
- package/src/runningAgentPanes.ts +96 -0
- package/src/snapshot.ts +8 -0
- package/src/stalled.ts +93 -0
- package/src/surfaceIdentity.ts +18 -0
- package/src/switcherLayout.ts +435 -0
- package/src/terminalTarget.ts +58 -0
- package/src/text.ts +114 -0
- package/src/tmux.ts +24 -0
- package/src/tmuxContext.ts +58 -0
package/src/ansiShell.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { loadSwitcherSnapshot } from './snapshot.js';
|
|
2
|
+
import { groupPanes, moveSelection, renderSwitcherFrame, selectablePanes, SwitcherRenderState } from './switcherLayout.js';
|
|
3
|
+
import { createStallTracker } from './stalled.js';
|
|
4
|
+
import { activateAgentPane } from './activation.js';
|
|
5
|
+
import { AgentPane } from './model.js';
|
|
6
|
+
|
|
7
|
+
function terminalSize(): { width: number; height: number } {
|
|
8
|
+
return {
|
|
9
|
+
width: process.stdout.columns || Number(process.env.COLUMNS) || 100,
|
|
10
|
+
height: process.stdout.rows || Number(process.env.ROWS) || 30,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function runAnsiSwitcher(): Promise<void> {
|
|
15
|
+
// Render the accepted prototype-style ANSI frame directly under Bun.
|
|
16
|
+
let state: SwitcherRenderState = {
|
|
17
|
+
useColor: Boolean(process.stdout.isTTY && !process.env.NO_COLOR),
|
|
18
|
+
home: process.env.HOME,
|
|
19
|
+
};
|
|
20
|
+
const stallTracker = createStallTracker();
|
|
21
|
+
let currentPanes = [] as ReturnType<typeof selectablePanes>;
|
|
22
|
+
let pendingActivation: AgentPane | undefined;
|
|
23
|
+
let closed = false;
|
|
24
|
+
let resolveClosed: (() => void) | undefined;
|
|
25
|
+
const closedPromise = new Promise<void>((resolve) => {
|
|
26
|
+
resolveClosed = resolve;
|
|
27
|
+
});
|
|
28
|
+
let redrawInFlight = false;
|
|
29
|
+
|
|
30
|
+
async function redraw(): Promise<void> {
|
|
31
|
+
if (closed || redrawInFlight) return;
|
|
32
|
+
redrawInFlight = true;
|
|
33
|
+
try {
|
|
34
|
+
state.frameIndex = (state.frameIndex ?? 0) + 1;
|
|
35
|
+
const snapshot = await loadSwitcherSnapshot({ stallTracker });
|
|
36
|
+
currentPanes = selectablePanes(groupPanes(snapshot.panes, snapshot.now, state.home));
|
|
37
|
+
const { width, height } = terminalSize();
|
|
38
|
+
const frame = renderSwitcherFrame(snapshot, width, height, state).join('\n');
|
|
39
|
+
process.stdout.write(`\x1b[2J\x1b[H${frame}`);
|
|
40
|
+
} finally {
|
|
41
|
+
redrawInFlight = false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function restoreTerminal(): void {
|
|
46
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
47
|
+
process.stdout.write('\x1b[?25h\x1b[?1049l');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function shutdown(): void {
|
|
51
|
+
if (closed) return;
|
|
52
|
+
closed = true;
|
|
53
|
+
clearInterval(interval);
|
|
54
|
+
process.stdout.off('resize', resizeHandler);
|
|
55
|
+
process.stdin.off('data', inputHandler);
|
|
56
|
+
process.stdin.pause();
|
|
57
|
+
process.off('SIGINT', sigintHandler);
|
|
58
|
+
restoreTerminal();
|
|
59
|
+
resolveClosed?.();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sigintHandler(): void {
|
|
63
|
+
shutdown();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resizeHandler(): void {
|
|
67
|
+
void redraw();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function inputHandler(buffer: Buffer): void {
|
|
71
|
+
const input = buffer.toString('utf8');
|
|
72
|
+
if (input === '\u0003' || input === 'q' || input === '\x1b') {
|
|
73
|
+
shutdown();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (input === '\x1b[A' || input === 'k') {
|
|
77
|
+
state.selectedPaneId = moveSelection(currentPanes, state.selectedPaneId, -1);
|
|
78
|
+
void redraw();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (input === '\x1b[B' || input === 'j') {
|
|
82
|
+
state.selectedPaneId = moveSelection(currentPanes, state.selectedPaneId, 1);
|
|
83
|
+
void redraw();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (input === '\r' || input === '\n') {
|
|
87
|
+
pendingActivation = currentPanes.find((pane) => pane.id === state.selectedPaneId) ?? currentPanes[0];
|
|
88
|
+
shutdown();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
process.stdout.write('\x1b[?1049h\x1b[?25l');
|
|
93
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
94
|
+
process.stdin.resume();
|
|
95
|
+
process.stdin.on('data', inputHandler);
|
|
96
|
+
process.stdout.on('resize', resizeHandler);
|
|
97
|
+
process.once('SIGINT', sigintHandler);
|
|
98
|
+
const interval = setInterval(() => void redraw(), 250);
|
|
99
|
+
await redraw();
|
|
100
|
+
|
|
101
|
+
await closedPromise;
|
|
102
|
+
|
|
103
|
+
if (pendingActivation) await activateAgentPane(pendingActivation);
|
|
104
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { runAnsiSwitcher } from './ansiShell.js';
|
|
3
|
+
import { loadSwitcherSnapshot } from './snapshot.js';
|
|
4
|
+
import { renderSwitcherFrame } from './switcherLayout.js';
|
|
5
|
+
import { stripAnsi } from './text.js';
|
|
6
|
+
import { startDaemon } from './daemon.js';
|
|
7
|
+
import { defaultSocketPath } from './ipc.js';
|
|
8
|
+
import { runEventCommand } from './eventCommand.js';
|
|
9
|
+
import { runIntegrationsInstall, runIntegrationsStatus } from './integrationsInstaller.js';
|
|
10
|
+
|
|
11
|
+
export async function main(argv = process.argv.slice(2)): Promise<number> {
|
|
12
|
+
const [command, ...rest] = argv;
|
|
13
|
+
if (command === 'help' || command === '--help' || command === '-h') {
|
|
14
|
+
process.stdout.write('watcher - open the Watcher Agent Switcher\n\nCommands:\n watcher\n watcher daemon\n watcher event [--quiet] <agent> <event>\n watcher integrations install <agents...>\n watcher integrations status\n');
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
if (command === 'daemon') {
|
|
18
|
+
const detached = rest.includes('--detach');
|
|
19
|
+
const server = await startDaemon({ socketPath: defaultSocketPath() });
|
|
20
|
+
if (!detached) process.stdout.write(`watcher daemon listening on ${defaultSocketPath()}\n`);
|
|
21
|
+
await new Promise<void>((resolve) => {
|
|
22
|
+
const close = () => server.close(() => resolve());
|
|
23
|
+
process.once('SIGINT', close);
|
|
24
|
+
process.once('SIGTERM', close);
|
|
25
|
+
});
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
if (command === 'event') {
|
|
29
|
+
return runEventCommand(rest);
|
|
30
|
+
}
|
|
31
|
+
if (command === 'integrations') {
|
|
32
|
+
const [subcommand, ...agents] = rest;
|
|
33
|
+
if (subcommand === 'install') {
|
|
34
|
+
try {
|
|
35
|
+
const result = await runIntegrationsInstall(agents);
|
|
36
|
+
process.stdout.write(result.output);
|
|
37
|
+
return result.code;
|
|
38
|
+
} catch (error) {
|
|
39
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (subcommand === 'status') {
|
|
44
|
+
process.stdout.write(await runIntegrationsStatus());
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
process.stderr.write('Usage: watcher integrations install <agents...> | watcher integrations status\n');
|
|
48
|
+
return 2;
|
|
49
|
+
}
|
|
50
|
+
if (command) {
|
|
51
|
+
process.stderr.write(`Unknown command: ${command}\n`);
|
|
52
|
+
return 2;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (process.env.WATCHER_TUI_SNAPSHOT || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
56
|
+
const snapshot = await loadSwitcherSnapshot();
|
|
57
|
+
const width = Number(process.env.COLUMNS) || process.stdout.columns || 100;
|
|
58
|
+
const height = Number(process.env.ROWS) || process.stdout.rows || 28;
|
|
59
|
+
const frame = renderSwitcherFrame(snapshot, width, height, { useColor: false, home: process.env.HOME });
|
|
60
|
+
process.stdout.write(`${frame.map(stripAnsi).join('\n')}\n`);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await runAnsiSwitcher();
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
main().then((code) => {
|
|
69
|
+
process.exitCode = code;
|
|
70
|
+
}, (error: unknown) => {
|
|
71
|
+
process.stderr.write(`${error instanceof Error ? error.stack || error.message : String(error)}\n`);
|
|
72
|
+
process.exitCode = 1;
|
|
73
|
+
});
|
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { AgentPane, SwitcherSnapshot } from './model.js';
|
|
4
|
+
import { WatcherAgentEventInput } from './agentEvents.js';
|
|
5
|
+
import { applyAgentEvent } from './agentEventReducer.js';
|
|
6
|
+
import { CommandRunner, hasTmuxServer, nodeCommandRunner } from './tmux.js';
|
|
7
|
+
import { getTmuxPane } from './tmuxContext.js';
|
|
8
|
+
import { terminalTargetCwd } from './terminalTarget.js';
|
|
9
|
+
import { discoverGitMetadata } from './git.js';
|
|
10
|
+
import { canonicalSurfaceKey } from './surfaceIdentity.js';
|
|
11
|
+
|
|
12
|
+
export type DaemonRequest =
|
|
13
|
+
| { type: 'event'; event: WatcherAgentEventInput }
|
|
14
|
+
| { type: 'snapshot' };
|
|
15
|
+
|
|
16
|
+
export type DaemonResponse =
|
|
17
|
+
| { ok: true; snapshot?: SwitcherSnapshot }
|
|
18
|
+
| { ok: false; error: string };
|
|
19
|
+
|
|
20
|
+
export class SnapshotStore {
|
|
21
|
+
private panes = new Map<string, AgentPane>();
|
|
22
|
+
|
|
23
|
+
async recordAgentEvent(event: WatcherAgentEventInput, runner: CommandRunner = nodeCommandRunner): Promise<AgentPane> {
|
|
24
|
+
const target = await getTmuxPane(event.surface.id, runner);
|
|
25
|
+
const cwd = event.payload.cwd ?? terminalTargetCwd(target);
|
|
26
|
+
const git = await discoverGitMetadata(cwd, runner);
|
|
27
|
+
const key = canonicalSurfaceKey(event.surface);
|
|
28
|
+
const previous = this.panes.get(key);
|
|
29
|
+
const pane = applyAgentEvent(previous, event, { target, cwd, git, now: event.now ?? Date.now() });
|
|
30
|
+
this.panes.set(pane.id, pane);
|
|
31
|
+
return pane;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
snapshot(tmuxAvailable = true, now = Date.now()): SwitcherSnapshot {
|
|
35
|
+
return {
|
|
36
|
+
panes: [...this.panes.values()],
|
|
37
|
+
daemonAvailable: true,
|
|
38
|
+
tmuxAvailable,
|
|
39
|
+
now,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DaemonOptions {
|
|
45
|
+
socketPath: string;
|
|
46
|
+
runner?: CommandRunner;
|
|
47
|
+
store?: SnapshotStore;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function startDaemon(options: DaemonOptions): Promise<net.Server> {
|
|
51
|
+
const runner = options.runner ?? nodeCommandRunner;
|
|
52
|
+
const store = options.store ?? new SnapshotStore();
|
|
53
|
+
await fs.rm(options.socketPath, { force: true }).catch(() => undefined);
|
|
54
|
+
const server = net.createServer((socket) => {
|
|
55
|
+
let body = '';
|
|
56
|
+
let handled = false;
|
|
57
|
+
socket.setEncoding('utf8');
|
|
58
|
+
socket.on('data', (chunk) => {
|
|
59
|
+
body += chunk;
|
|
60
|
+
if (!handled && body.includes('\n')) {
|
|
61
|
+
handled = true;
|
|
62
|
+
const [line] = body.split('\n');
|
|
63
|
+
void handleRequest(line ?? body, store, runner).then((response) => {
|
|
64
|
+
socket.end(`${JSON.stringify(response)}\n`);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
socket.on('end', () => {
|
|
69
|
+
if (handled) return;
|
|
70
|
+
handled = true;
|
|
71
|
+
void handleRequest(body, store, runner).then((response) => {
|
|
72
|
+
socket.end(`${JSON.stringify(response)}\n`);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
await new Promise<void>((resolve, reject) => {
|
|
77
|
+
server.once('error', reject);
|
|
78
|
+
server.listen(options.socketPath, () => {
|
|
79
|
+
server.off('error', reject);
|
|
80
|
+
resolve();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
return server;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function handleRequest(body: string, store: SnapshotStore, runner: CommandRunner): Promise<DaemonResponse> {
|
|
87
|
+
try {
|
|
88
|
+
const request = JSON.parse(body) as DaemonRequest;
|
|
89
|
+
if (request.type === 'event') {
|
|
90
|
+
await store.recordAgentEvent(request.event, runner);
|
|
91
|
+
return { ok: true };
|
|
92
|
+
}
|
|
93
|
+
if (request.type === 'snapshot') {
|
|
94
|
+
const tmuxAvailable = await hasTmuxServer(runner);
|
|
95
|
+
return { ok: true, snapshot: store.snapshot(tmuxAvailable) };
|
|
96
|
+
}
|
|
97
|
+
return { ok: false, error: 'unknown request type' };
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/discovery.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { AgentPane, TmuxTarget } from './model.js';
|
|
2
|
+
import { detectAgentFromProcess, getAgentIntegration } from './agents/registry.js';
|
|
3
|
+
import { canonicalSurfaceKey, surfaceFromTarget } from './surfaceIdentity.js';
|
|
4
|
+
import { terminalTargetCommand, terminalTargetCwd, terminalTargetPid } from './terminalTarget.js';
|
|
5
|
+
import { CommandRunner, nodeCommandRunner } from './tmux.js';
|
|
6
|
+
import { captureTmuxPanePreview, listTmuxPanes } from './tmuxContext.js';
|
|
7
|
+
import { discoverGitMetadata } from './git.js';
|
|
8
|
+
|
|
9
|
+
export interface TerminalAgentObservation {
|
|
10
|
+
tmuxAvailable: boolean;
|
|
11
|
+
livePaneIds: Set<string>;
|
|
12
|
+
liveAgentProcessPaneIds: Set<string>;
|
|
13
|
+
discoveredPanes: AgentPane[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function childProcessCommands(parentPid: number | undefined, runner: CommandRunner): Promise<string[]> {
|
|
17
|
+
if (!parentPid || Number.isNaN(parentPid)) return [];
|
|
18
|
+
try {
|
|
19
|
+
const pids = (await runner.execFile('pgrep', ['-P', String(parentPid)], { timeout: 1000 })).stdout.split(/\s+/).filter(Boolean);
|
|
20
|
+
const commands: string[] = [];
|
|
21
|
+
for (const pid of pids) {
|
|
22
|
+
try {
|
|
23
|
+
const command = (await runner.execFile('ps', ['-p', pid, '-o', 'comm='], { timeout: 1000 })).stdout.trim();
|
|
24
|
+
if (command) commands.push(command);
|
|
25
|
+
} catch {
|
|
26
|
+
// One rude child process should not tank discovery. Tiny process gremlins happen.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return commands;
|
|
30
|
+
} catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function detectKnownAgent(pane: TmuxTarget, runner: CommandRunner = nodeCommandRunner): Promise<AgentPane['agentType'] | undefined> {
|
|
36
|
+
const direct = detectAgentFromProcess({ command: terminalTargetCommand(pane) });
|
|
37
|
+
if (direct) return direct;
|
|
38
|
+
for (const command of await childProcessCommands(terminalTargetPid(pane), runner)) {
|
|
39
|
+
const child = detectAgentFromProcess({ command });
|
|
40
|
+
if (child) return child;
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function observeTerminalAgentPanes(runner: CommandRunner = nodeCommandRunner, now = Date.now()): Promise<TerminalAgentObservation> {
|
|
46
|
+
let panes: TmuxTarget[];
|
|
47
|
+
try {
|
|
48
|
+
panes = await listTmuxPanes(runner);
|
|
49
|
+
} catch {
|
|
50
|
+
return { tmuxAvailable: false, livePaneIds: new Set(), liveAgentProcessPaneIds: new Set(), discoveredPanes: [] };
|
|
51
|
+
}
|
|
52
|
+
const discovered: AgentPane[] = [];
|
|
53
|
+
for (const tmux of panes) {
|
|
54
|
+
const agentType = await detectKnownAgent(tmux, runner);
|
|
55
|
+
if (!agentType) continue;
|
|
56
|
+
const cwd = terminalTargetCwd(tmux);
|
|
57
|
+
const integration = getAgentIntegration(agentType);
|
|
58
|
+
const waitsForEvents = integration.capabilities.eventSourceInstall === 'supported';
|
|
59
|
+
const terminalPreview = await captureTmuxPanePreview(tmux.paneId, runner);
|
|
60
|
+
discovered.push({
|
|
61
|
+
id: canonicalSurfaceKey(surfaceFromTarget(tmux)),
|
|
62
|
+
agentType,
|
|
63
|
+
status: 'unknown',
|
|
64
|
+
summary: waitsForEvents ? 'Waiting for first Watcher event' : `Detected ${agentType} process`,
|
|
65
|
+
currentAction: 'tmux/process discovery fallback',
|
|
66
|
+
observation: {
|
|
67
|
+
source: 'terminal',
|
|
68
|
+
semanticEvents: false,
|
|
69
|
+
assistantDeltas: false,
|
|
70
|
+
terminalPreview: Boolean(terminalPreview),
|
|
71
|
+
},
|
|
72
|
+
terminalPreview,
|
|
73
|
+
target: tmux,
|
|
74
|
+
cwd,
|
|
75
|
+
git: await discoverGitMetadata(cwd, runner),
|
|
76
|
+
updatedAt: now,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
tmuxAvailable: true,
|
|
81
|
+
livePaneIds: new Set(panes.map((pane) => canonicalSurfaceKey(surfaceFromTarget(pane)))),
|
|
82
|
+
liveAgentProcessPaneIds: new Set(discovered.map((pane) => pane.id)),
|
|
83
|
+
discoveredPanes: discovered,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
3
|
+
import type { DaemonRequest, DaemonResponse } from './daemon.js';
|
|
4
|
+
import { sendDaemonRequest } from './ipc.js';
|
|
5
|
+
import { AgentEventValidationError, buildWatcherAgentEventInput, readJsonObject, WatcherAgentEventInput } from './agentEvents.js';
|
|
6
|
+
|
|
7
|
+
export async function readStdin(): Promise<string> {
|
|
8
|
+
const chunks: Buffer[] = [];
|
|
9
|
+
for await (const chunk of process.stdin) {
|
|
10
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
11
|
+
}
|
|
12
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface EventDeliveryDeps {
|
|
16
|
+
send?: (request: DaemonRequest) => Promise<DaemonResponse>;
|
|
17
|
+
startDaemon?: () => void;
|
|
18
|
+
sleep?: (ms: number) => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface EventCommandDeps extends EventDeliveryDeps {
|
|
22
|
+
readInput?: () => Promise<string>;
|
|
23
|
+
env?: NodeJS.ProcessEnv;
|
|
24
|
+
stderr?: (message: string) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function startDetachedDaemon(): void {
|
|
28
|
+
const cliPath = process.argv[1];
|
|
29
|
+
if (!cliPath) return;
|
|
30
|
+
const child = spawn(process.execPath, [cliPath, 'daemon', '--detach'], {
|
|
31
|
+
detached: true,
|
|
32
|
+
stdio: 'ignore',
|
|
33
|
+
env: process.env,
|
|
34
|
+
});
|
|
35
|
+
child.unref();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function deliverAgentEvent(event: WatcherAgentEventInput, deps: EventDeliveryDeps = {}): Promise<boolean> {
|
|
39
|
+
const request: DaemonRequest = { type: 'event', event };
|
|
40
|
+
const send = deps.send ?? ((daemonRequest) => sendDaemonRequest(daemonRequest, { timeoutMs: 300 }));
|
|
41
|
+
const nap = deps.sleep ?? sleep;
|
|
42
|
+
try {
|
|
43
|
+
const response = await send(request);
|
|
44
|
+
if (response.ok) return true;
|
|
45
|
+
} catch {
|
|
46
|
+
// absent daemon; try to bring it up below
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
(deps.startDaemon ?? startDetachedDaemon)();
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const retryDelays = [50, 100, 200, 300];
|
|
54
|
+
for (const delay of retryDelays) {
|
|
55
|
+
await nap(delay);
|
|
56
|
+
try {
|
|
57
|
+
const response = await send(request);
|
|
58
|
+
if (response.ok) return true;
|
|
59
|
+
} catch {
|
|
60
|
+
// keep the retry loop bounded; event shims are not a place to cosplay systemd.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface ParsedEventArgs {
|
|
67
|
+
quiet: boolean;
|
|
68
|
+
agent?: string;
|
|
69
|
+
event?: string;
|
|
70
|
+
validShape: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseEventArgs(args: string[]): ParsedEventArgs {
|
|
74
|
+
if (args[0] === '--quiet') {
|
|
75
|
+
return { quiet: true, agent: args[1], event: args[2], validShape: args.length === 3 };
|
|
76
|
+
}
|
|
77
|
+
return { quiet: false, agent: args[0], event: args[1], validShape: args.length === 2 };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function reportError(quiet: boolean, stderr: (message: string) => void, message: string): number {
|
|
81
|
+
if (!quiet) stderr(`${message}\n`);
|
|
82
|
+
return quiet ? 0 : 2;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function runEventCommand(args: string[], deps: EventCommandDeps = {}): Promise<number> {
|
|
86
|
+
const parsed = parseEventArgs(args);
|
|
87
|
+
const stderr = deps.stderr ?? ((message) => process.stderr.write(message));
|
|
88
|
+
if (!parsed.validShape) {
|
|
89
|
+
return reportError(parsed.quiet, stderr, 'Usage: watcher event [--quiet] <agent> <event>');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let payload: Record<string, unknown>;
|
|
93
|
+
try {
|
|
94
|
+
payload = readJsonObject(await (deps.readInput ?? readStdin)());
|
|
95
|
+
} catch (error) {
|
|
96
|
+
return reportError(parsed.quiet, stderr, error instanceof Error ? error.message : String(error));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let event: WatcherAgentEventInput;
|
|
100
|
+
try {
|
|
101
|
+
const env = deps.env ?? process.env;
|
|
102
|
+
event = buildWatcherAgentEventInput(parsed.agent, parsed.event, payload, { fallbackTmuxPaneId: env.TMUX_PANE });
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error instanceof AgentEventValidationError) return reportError(parsed.quiet, stderr, error.message);
|
|
105
|
+
return reportError(parsed.quiet, stderr, error instanceof Error ? error.message : String(error));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
await deliverAgentEvent(event, deps);
|
|
110
|
+
} catch {
|
|
111
|
+
// Agent Event Sources must never break the agent. Fail open like a polite little gremlin.
|
|
112
|
+
}
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { basename } from './text.js';
|
|
2
|
+
import { CommandRunner, nodeCommandRunner } from './tmux.js';
|
|
3
|
+
import { GitMetadata } from './model.js';
|
|
4
|
+
|
|
5
|
+
async function git(args: string[], runner: CommandRunner, cwd: string): Promise<string | undefined> {
|
|
6
|
+
try {
|
|
7
|
+
const result = await runner.execFile('git', ['-C', cwd, ...args], { timeout: 1000 });
|
|
8
|
+
return result.stdout.trim() || undefined;
|
|
9
|
+
} catch {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function discoverGitMetadata(cwd: string | undefined, runner: CommandRunner = nodeCommandRunner): Promise<GitMetadata | undefined> {
|
|
15
|
+
if (!cwd) return undefined;
|
|
16
|
+
const worktreePath = await git(['rev-parse', '--show-toplevel'], runner, cwd);
|
|
17
|
+
if (!worktreePath) return undefined;
|
|
18
|
+
const branch = await git(['branch', '--show-current'], runner, cwd)
|
|
19
|
+
?? await git(['rev-parse', '--short', 'HEAD'], runner, cwd)
|
|
20
|
+
?? 'unknown';
|
|
21
|
+
const commonDir = await git(['rev-parse', '--path-format=absolute', '--git-common-dir'], runner, cwd);
|
|
22
|
+
const repoFromCommonDir = commonDir?.endsWith('/.git') ? basename(commonDir.slice(0, -5)) : undefined;
|
|
23
|
+
return {
|
|
24
|
+
repo: repoFromCommonDir || basename(worktreePath),
|
|
25
|
+
branch,
|
|
26
|
+
worktreePath,
|
|
27
|
+
};
|
|
28
|
+
}
|