@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.
@@ -0,0 +1,218 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import type { AgentType } from './model.js';
4
+ import { AGENT_INTEGRATIONS, getAgentIntegration, isAgentType } from './agents/registry.js';
5
+ import type { AgentIntegration } from './agents/types.js';
6
+
7
+ export const WATCHER_MARKER = 'WATCHER_AGENT_EVENT_SOURCE_MANAGED';
8
+ const LEGACY_WATCHER_MARKER = 'WATCHER_STATUS_HOOK_MANAGED';
9
+ export const PI_EXTENSION_RELATIVE = path.join('.pi', 'agent', 'extensions', 'watcher-status.ts');
10
+
11
+ export type IntegrationState = 'installed' | 'outdated' | 'missing' | 'conflict' | 'not-implemented';
12
+
13
+ export interface IntegrationInstallOptions {
14
+ home?: string;
15
+ }
16
+
17
+ export interface IntegrationStatus {
18
+ agent: AgentType;
19
+ state: IntegrationState;
20
+ path?: string;
21
+ }
22
+
23
+ export function piExtensionPath(home = process.env.HOME || ''): string {
24
+ return path.join(home, PI_EXTENSION_RELATIVE);
25
+ }
26
+
27
+ export function generatePiExtension(): string {
28
+ return `// ${WATCHER_MARKER}
29
+ // Generated by watcher integrations install pi. Safe to overwrite by Watcher.
30
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
31
+ import { spawn, spawnSync } from "node:child_process";
32
+
33
+ function textFromContent(content: unknown): string | undefined {
34
+ if (typeof content === "string") return content;
35
+ if (Array.isArray(content)) {
36
+ const text = content
37
+ .map((part) => part && typeof part === "object" && "text" in part ? String((part as { text?: unknown }).text ?? "") : "")
38
+ .filter(Boolean)
39
+ .join("\\n");
40
+ return text || undefined;
41
+ }
42
+ return undefined;
43
+ }
44
+
45
+ function assistantMessageText(message: unknown): string | undefined {
46
+ const candidate = message as { role?: unknown; content?: unknown } | undefined;
47
+ if (candidate?.role !== "assistant") return undefined;
48
+ return textFromContent(candidate.content);
49
+ }
50
+
51
+ function lastAssistantMessage(messages: unknown): string | undefined {
52
+ if (!Array.isArray(messages)) return undefined;
53
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
54
+ const text = assistantMessageText(messages[index]);
55
+ if (text) return text;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ function compactUnknown(value: unknown): string | undefined {
61
+ if (value === undefined || value === null) return undefined;
62
+ let text: string;
63
+ try {
64
+ text = typeof value === "string" ? value : JSON.stringify(value);
65
+ } catch {
66
+ text = String(value);
67
+ }
68
+ return text.replace(/\\s+/g, " ").trim().slice(0, 16000) || undefined;
69
+ }
70
+
71
+ function payloadWithSurface(payload: Record<string, unknown>): Record<string, unknown> {
72
+ const tmuxPane = process.env.TMUX_PANE;
73
+ const surface = tmuxPane ? { backend: "tmux" as const, id: tmuxPane } : undefined;
74
+ return surface ? { surface, ...payload } : payload;
75
+ }
76
+
77
+ function report(event: string, payload: Record<string, unknown> = {}) {
78
+ try {
79
+ const child = spawn("watcher", ["event", "--quiet", "pi", event], {
80
+ stdio: ["pipe", "ignore", "ignore"],
81
+ detached: true,
82
+ env: process.env,
83
+ });
84
+ child.stdin.end(JSON.stringify(payloadWithSurface(payload)));
85
+ child.unref();
86
+ } catch {
87
+ // Watcher integrations must never break Pi. If this fails, shrug in lowercase.
88
+ }
89
+ }
90
+
91
+ function reportSync(event: string, payload: Record<string, unknown> = {}) {
92
+ try {
93
+ spawnSync("watcher", ["event", "--quiet", "pi", event], {
94
+ input: JSON.stringify(payloadWithSurface(payload)),
95
+ stdio: ["pipe", "ignore", "ignore"],
96
+ env: process.env,
97
+ timeout: 1500,
98
+ });
99
+ } catch {
100
+ // Shutdown hooks get one best-effort synchronous poke. No drama, no broken Pi exit.
101
+ }
102
+ }
103
+
104
+ export default function watcherAgentEventSource(pi: ExtensionAPI) {
105
+ pi.on("session_start", async (event, ctx) => {
106
+ report("session-started", { reason: event.reason, cwd: ctx.cwd });
107
+ });
108
+
109
+ pi.on("before_agent_start", async (event, ctx) => {
110
+ report("user-message", { text: event.prompt, cwd: ctx.cwd });
111
+ });
112
+
113
+ pi.on("agent_start", async (_event, ctx) => {
114
+ report("agent-started", { cwd: ctx.cwd });
115
+ });
116
+
117
+ pi.on("message_end", async (event, ctx) => {
118
+ const text = assistantMessageText(event.message);
119
+ if (text) report("assistant-message", { text, cwd: ctx.cwd });
120
+ });
121
+
122
+ pi.on("tool_execution_start", async (event, ctx) => {
123
+ report("tool-started", { id: event.toolCallId, name: event.toolName, input: compactUnknown(event.args), cwd: ctx.cwd });
124
+ });
125
+
126
+ pi.on("tool_execution_update", async (event, ctx) => {
127
+ report("tool-updated", { id: event.toolCallId, name: event.toolName, text: compactUnknown(event.partialResult), cwd: ctx.cwd });
128
+ });
129
+
130
+ pi.on("tool_execution_end", async (event, ctx) => {
131
+ report("tool-finished", { id: event.toolCallId, name: event.toolName, output: compactUnknown(event.result), error: Boolean(event.isError), cwd: ctx.cwd });
132
+ });
133
+
134
+ pi.on("agent_end", async (event, ctx) => {
135
+ report("agent-finished", { finalMessage: lastAssistantMessage(event.messages), cwd: ctx.cwd });
136
+ });
137
+
138
+ pi.on("session_shutdown", async (event, ctx) => {
139
+ if (event.reason === "quit") {
140
+ reportSync("agent-finished", { finalMessage: "Session quit", cwd: ctx.cwd });
141
+ }
142
+ });
143
+ }
144
+ `;
145
+ }
146
+
147
+ function piIntegrationState(content: string): IntegrationState {
148
+ if (content.includes(WATCHER_MARKER)) return 'installed';
149
+ if (content.includes(LEGACY_WATCHER_MARKER)) return 'outdated';
150
+ return 'conflict';
151
+ }
152
+
153
+ export async function getPiIntegrationStatus(options: IntegrationInstallOptions = {}): Promise<IntegrationStatus> {
154
+ const target = piExtensionPath(options.home);
155
+ try {
156
+ const current = await fs.readFile(target, 'utf8');
157
+ return { agent: 'pi', state: piIntegrationState(current), path: target };
158
+ } catch (error) {
159
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { agent: 'pi', state: 'missing', path: target };
160
+ throw error;
161
+ }
162
+ }
163
+
164
+ export async function installPiIntegration(options: IntegrationInstallOptions = {}): Promise<IntegrationStatus> {
165
+ const status = await getPiIntegrationStatus(options);
166
+ if (status.state === 'conflict') {
167
+ throw new Error(`Refusing to overwrite non-Watcher Pi extension at ${status.path}`);
168
+ }
169
+ if (!status.path) throw new Error('Pi extension path unavailable');
170
+ await fs.mkdir(path.dirname(status.path), { recursive: true });
171
+ await fs.writeFile(status.path, generatePiExtension(), 'utf8');
172
+ return { ...status, state: 'installed' };
173
+ }
174
+
175
+ function capabilitySummary(integration: AgentIntegration): string {
176
+ const caps = integration.capabilities;
177
+ return `event ingestion ${caps.eventIngestion}, install ${caps.eventSourceInstall}, activity ${caps.activityEvents}, deltas ${caps.assistantDeltas}`;
178
+ }
179
+
180
+ function dedupeAgents(agents: string[]): string[] {
181
+ return [...new Set(agents)];
182
+ }
183
+
184
+ export async function runIntegrationsInstall(agents: string[], options: IntegrationInstallOptions = {}): Promise<{ code: number; output: string }> {
185
+ const requested = dedupeAgents(agents);
186
+ if (requested.length === 0) return { code: 2, output: 'Usage: watcher integrations install <agents...>\n' };
187
+ const unknown = requested.filter((agent) => !isAgentType(agent));
188
+ if (unknown.length > 0) return { code: 2, output: `Unknown agent integration(s): ${unknown.join(', ')}\n` };
189
+
190
+ const notInstallable = requested
191
+ .map((agent) => getAgentIntegration(agent as AgentType))
192
+ .filter((integration) => integration.capabilities.eventSourceInstall !== 'supported')
193
+ .map((integration) => integration.type);
194
+ if (notInstallable.length > 0) return { code: 2, output: `Installer not implemented for: ${notInstallable.join(', ')}\n` };
195
+
196
+ const lines: string[] = [];
197
+ for (const agent of requested as AgentType[]) {
198
+ if (agent === 'pi') {
199
+ const status = await installPiIntegration(options);
200
+ lines.push(`Installed Pi Agent Event Source at ${status.path}`);
201
+ lines.push('Run /reload in existing Pi panes or restart Pi for the integration to load.');
202
+ }
203
+ }
204
+ return { code: 0, output: `${lines.join('\n')}\n` };
205
+ }
206
+
207
+ export async function runIntegrationsStatus(options: IntegrationInstallOptions = {}): Promise<string> {
208
+ const lines: string[] = [];
209
+ for (const integration of AGENT_INTEGRATIONS) {
210
+ if (integration.type === 'pi') {
211
+ const status = await getPiIntegrationStatus(options);
212
+ lines.push(`pi: ${status.state}${status.path ? ` at ${status.path}` : ''} — ${capabilitySummary(integration)}`);
213
+ continue;
214
+ }
215
+ lines.push(`${integration.type}: installer not implemented — ${capabilitySummary(integration)}`);
216
+ }
217
+ return `${lines.join('\n')}\n`;
218
+ }
package/src/ipc.ts ADDED
@@ -0,0 +1,56 @@
1
+ import fs from 'node:fs';
2
+ import net from 'node:net';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { DaemonRequest, DaemonResponse } from './daemon.js';
6
+
7
+ export function defaultSocketPath(): string {
8
+ return process.env.WATCHER_SOCKET || path.join(os.tmpdir(), `watcher-${process.getuid?.() ?? 'user'}.sock`);
9
+ }
10
+
11
+ export interface SendOptions {
12
+ socketPath?: string;
13
+ timeoutMs?: number;
14
+ }
15
+
16
+ export async function sendDaemonRequest(request: DaemonRequest, options: SendOptions = {}): Promise<DaemonResponse> {
17
+ const socketPath = options.socketPath ?? defaultSocketPath();
18
+ const timeoutMs = options.timeoutMs ?? 500;
19
+ if (!fs.existsSync(socketPath)) {
20
+ throw new Error(`daemon socket not found: ${socketPath}`);
21
+ }
22
+ return new Promise<DaemonResponse>((resolve, reject) => {
23
+ const socket = net.createConnection(socketPath);
24
+ let settled = false;
25
+ let body = '';
26
+ const timer = setTimeout(() => {
27
+ finish(() => reject(new Error('daemon request timed out')));
28
+ }, timeoutMs);
29
+ function finish(callback: () => void): void {
30
+ if (settled) return;
31
+ settled = true;
32
+ clearTimeout(timer);
33
+ socket.destroy();
34
+ callback();
35
+ }
36
+ socket.setEncoding('utf8');
37
+ socket.on('connect', () => {
38
+ socket.write(`${JSON.stringify(request)}\n`);
39
+ });
40
+ socket.on('data', (chunk) => {
41
+ body += chunk;
42
+ });
43
+ socket.on('end', () => {
44
+ finish(() => {
45
+ try {
46
+ resolve(JSON.parse(body) as DaemonResponse);
47
+ } catch (error) {
48
+ reject(error);
49
+ }
50
+ });
51
+ });
52
+ socket.on('error', (error) => {
53
+ finish(() => reject(error));
54
+ });
55
+ });
56
+ }
package/src/model.ts ADDED
@@ -0,0 +1,94 @@
1
+ export type AgentStatus = 'working' | 'needs_input' | 'stalled' | 'unknown' | 'idle';
2
+
3
+ export type AgentType = 'pi' | 'claude' | 'codex' | 'opencode';
4
+
5
+ export type TerminalBackend = 'tmux';
6
+
7
+ export interface BaseTerminalTarget {
8
+ backend: TerminalBackend;
9
+ id: string;
10
+ cwd?: string;
11
+ title?: string;
12
+ pid?: number;
13
+ currentCommand?: string;
14
+ }
15
+
16
+ export interface TmuxTarget extends BaseTerminalTarget {
17
+ backend: 'tmux';
18
+ paneId: string;
19
+ sessionName?: string;
20
+ windowIndex?: string;
21
+ paneIndex?: string;
22
+ paneCurrentPath?: string;
23
+ panePid?: number;
24
+ paneCurrentCommand?: string;
25
+ windowName?: string;
26
+ paneTitle?: string;
27
+ }
28
+
29
+ export type TerminalTarget = TmuxTarget;
30
+
31
+ export interface GitMetadata {
32
+ repo: string;
33
+ branch: string;
34
+ worktreePath: string;
35
+ }
36
+
37
+ export interface AgentActivityItem {
38
+ id: string;
39
+ kind: 'assistant' | 'tool';
40
+ label: string;
41
+ text?: string;
42
+ state?: 'running' | 'done' | 'error' | 'waiting';
43
+ updatedAt: number;
44
+ }
45
+
46
+ export interface ObservationCapability {
47
+ source: 'event-source' | 'terminal' | 'mixed';
48
+ semanticEvents: boolean;
49
+ assistantDeltas: boolean;
50
+ terminalPreview: boolean;
51
+ }
52
+
53
+ export interface AgentPane {
54
+ id: string;
55
+ agentType: AgentType;
56
+ status: AgentStatus;
57
+ summary: string;
58
+ userMessage?: string;
59
+ currentAction?: string;
60
+ lastMessage?: string;
61
+ pendingAssistantMessage?: string;
62
+ activityItems?: AgentActivityItem[];
63
+ observation?: ObservationCapability;
64
+ target: TerminalTarget;
65
+ cwd?: string;
66
+ git?: GitMetadata;
67
+ updatedAt: number;
68
+ reportedStatus?: Exclude<AgentStatus, 'stalled'>;
69
+ terminalPreview?: string;
70
+ outputHash?: string;
71
+ outputChangedAt?: number;
72
+ }
73
+
74
+ export interface SwitcherSnapshot {
75
+ panes: AgentPane[];
76
+ daemonAvailable: boolean;
77
+ tmuxAvailable: boolean;
78
+ message?: string;
79
+ now?: number;
80
+ }
81
+
82
+ export const RUNNING_AGENT_STATUSES: AgentStatus[] = ['needs_input', 'stalled', 'working', 'unknown', 'idle'];
83
+
84
+ export const STATUS_RANK: Record<AgentStatus, number> = {
85
+ needs_input: 0,
86
+ stalled: 1,
87
+ working: 2,
88
+ unknown: 3,
89
+ idle: 4,
90
+ };
91
+
92
+ export function isRunningAgentStatus(status: AgentStatus): boolean {
93
+ return RUNNING_AGENT_STATUSES.includes(status);
94
+ }
@@ -0,0 +1,96 @@
1
+ import { AgentPane, SwitcherSnapshot } from './model.js';
2
+ import { CommandRunner, hasTmuxServer, nodeCommandRunner } from './tmux.js';
3
+ import { sendDaemonRequest } from './ipc.js';
4
+ import { observeTerminalAgentPanes, TerminalAgentObservation } from './discovery.js';
5
+ import { captureTmuxPanePreview } from './tmuxContext.js';
6
+ import { deriveStalledStatuses, StallTracker } from './stalled.js';
7
+ import { normalizeAgentPaneTarget } from './terminalTarget.js';
8
+
9
+ export interface RunningAgentPaneSnapshotOptions {
10
+ runner?: CommandRunner;
11
+ now?: number;
12
+ stallTracker?: StallTracker;
13
+ stalledMs?: number;
14
+ socketPath?: string;
15
+ }
16
+
17
+ async function readDaemonSnapshot(socketPath: string | undefined): Promise<SwitcherSnapshot | undefined> {
18
+ try {
19
+ const response = await sendDaemonRequest({ type: 'snapshot' }, { timeoutMs: 300, socketPath });
20
+ if (!response.ok || !response.snapshot) return undefined;
21
+ return {
22
+ ...response.snapshot,
23
+ panes: response.snapshot.panes
24
+ .map((pane) => normalizeAgentPaneTarget(pane))
25
+ .filter((pane): pane is AgentPane => pane !== undefined),
26
+ };
27
+ } catch {
28
+ // No daemon yet; render an honest empty state instead of faceplanting like a fragile dashboard goblin.
29
+ return undefined;
30
+ }
31
+ }
32
+
33
+ function daemonPaneIsRunning(pane: AgentPane, observation: TerminalAgentObservation): boolean {
34
+ if (!observation.tmuxAvailable) return true;
35
+ if (!observation.livePaneIds.has(pane.id)) return false;
36
+ return observation.liveAgentProcessPaneIds.has(pane.id);
37
+ }
38
+
39
+ function reconcileRunningAgentPanes(daemonPanes: AgentPane[], observation: TerminalAgentObservation): AgentPane[] {
40
+ const result = new Map<string, AgentPane>();
41
+ for (const pane of daemonPanes) {
42
+ const normalized = normalizeAgentPaneTarget(pane);
43
+ if (!normalized) continue;
44
+ if (!daemonPaneIsRunning(normalized, observation)) continue;
45
+ result.set(normalized.id, normalized);
46
+ }
47
+ for (const pane of observation.discoveredPanes) {
48
+ const normalized = normalizeAgentPaneTarget(pane);
49
+ if (normalized && !result.has(normalized.id)) result.set(normalized.id, normalized);
50
+ }
51
+ return [...result.values()];
52
+ }
53
+
54
+ async function attachTerminalPreviews(panes: AgentPane[], runner: CommandRunner): Promise<AgentPane[]> {
55
+ return Promise.all(panes.map(async (pane) => {
56
+ if (pane.terminalPreview || pane.target.backend !== 'tmux') return pane;
57
+ const terminalPreview = await captureTmuxPanePreview(pane.target.paneId, runner);
58
+ if (!terminalPreview) return pane;
59
+ return {
60
+ ...pane,
61
+ terminalPreview,
62
+ observation: {
63
+ source: pane.observation?.source ?? 'mixed',
64
+ semanticEvents: pane.observation?.semanticEvents ?? true,
65
+ assistantDeltas: pane.observation?.assistantDeltas ?? false,
66
+ terminalPreview: true,
67
+ },
68
+ };
69
+ }));
70
+ }
71
+
72
+ export async function loadRunningAgentPaneSnapshot(options: RunningAgentPaneSnapshotOptions = {}): Promise<SwitcherSnapshot> {
73
+ const runner = options.runner ?? nodeCommandRunner;
74
+ const now = options.now ?? Date.now();
75
+ const daemonSnapshot = await readDaemonSnapshot(options.socketPath);
76
+ const observation = await observeTerminalAgentPanes(runner, now);
77
+
78
+ if (observation.tmuxAvailable) {
79
+ const runningPanes = reconcileRunningAgentPanes(daemonSnapshot?.panes ?? [], observation);
80
+ const panesWithPreviews = await attachTerminalPreviews(runningPanes, runner);
81
+ return {
82
+ panes: await deriveStalledStatuses(panesWithPreviews, { now, runner, tracker: options.stallTracker, stalledMs: options.stalledMs }),
83
+ daemonAvailable: daemonSnapshot?.daemonAvailable ?? false,
84
+ tmuxAvailable: true,
85
+ now,
86
+ };
87
+ }
88
+
89
+ const tmuxAvailable = daemonSnapshot?.tmuxAvailable ?? await hasTmuxServer(runner);
90
+ return {
91
+ panes: daemonSnapshot?.panes ?? [],
92
+ daemonAvailable: daemonSnapshot?.daemonAvailable ?? false,
93
+ tmuxAvailable,
94
+ now,
95
+ };
96
+ }
@@ -0,0 +1,8 @@
1
+ import { SwitcherSnapshot } from './model.js';
2
+ import { loadRunningAgentPaneSnapshot, RunningAgentPaneSnapshotOptions } from './runningAgentPanes.js';
3
+
4
+ export type SnapshotOptions = RunningAgentPaneSnapshotOptions;
5
+
6
+ export async function loadSwitcherSnapshot(options: SnapshotOptions = {}): Promise<SwitcherSnapshot> {
7
+ return loadRunningAgentPaneSnapshot(options);
8
+ }
package/src/stalled.ts ADDED
@@ -0,0 +1,93 @@
1
+ import crypto from 'node:crypto';
2
+ import { AgentPane } from './model.js';
3
+ import { terminalTargetTitle } from './terminalTarget.js';
4
+ import { CommandRunner, nodeCommandRunner } from './tmux.js';
5
+
6
+ export const DEFAULT_STALLED_MS = 5 * 60 * 1000;
7
+
8
+ export interface StallEntry {
9
+ lastActivityAt: number;
10
+ eventUpdatedAt: number;
11
+ outputHash?: string;
12
+ title?: string;
13
+ }
14
+
15
+ export interface StallTracker {
16
+ entries: Map<string, StallEntry>;
17
+ }
18
+
19
+ export function createStallTracker(): StallTracker {
20
+ return { entries: new Map() };
21
+ }
22
+
23
+ export async function capturePaneTailHash(paneId: string, runner: CommandRunner = nodeCommandRunner): Promise<string | undefined> {
24
+ try {
25
+ const result = await runner.execFile('tmux', ['capture-pane', '-p', '-t', paneId, '-S', '-200'], { timeout: 1000 });
26
+ return crypto.createHash('sha1').update(result.stdout).digest('hex');
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ }
31
+
32
+ export interface StalledOptions {
33
+ now?: number;
34
+ stalledMs?: number;
35
+ runner?: CommandRunner;
36
+ tracker?: StallTracker;
37
+ }
38
+
39
+ function observedTitle(pane: AgentPane): string | undefined {
40
+ return terminalTargetTitle(pane.target);
41
+ }
42
+
43
+ export async function deriveStalledStatuses(panes: AgentPane[], options: StalledOptions = {}): Promise<AgentPane[]> {
44
+ const now = options.now ?? Date.now();
45
+ const stalledMs = options.stalledMs ?? DEFAULT_STALLED_MS;
46
+ const runner = options.runner ?? nodeCommandRunner;
47
+ const tracker = options.tracker ?? createStallTracker();
48
+ const liveIds = new Set(panes.map((pane) => pane.id));
49
+ for (const id of tracker.entries.keys()) {
50
+ if (!liveIds.has(id)) tracker.entries.delete(id);
51
+ }
52
+
53
+ const result: AgentPane[] = [];
54
+ for (const pane of panes) {
55
+ if (pane.status !== 'working') {
56
+ if (pane.status === 'idle') tracker.entries.delete(pane.id);
57
+ else tracker.entries.set(pane.id, {
58
+ lastActivityAt: pane.updatedAt,
59
+ eventUpdatedAt: pane.updatedAt,
60
+ outputHash: pane.outputHash,
61
+ title: observedTitle(pane),
62
+ });
63
+ result.push(pane);
64
+ continue;
65
+ }
66
+
67
+ const outputHash = await capturePaneTailHash(pane.target.paneId, runner);
68
+ const title = observedTitle(pane);
69
+ const previous = tracker.entries.get(pane.id);
70
+ let entry: StallEntry = previous ?? {
71
+ lastActivityAt: pane.updatedAt,
72
+ eventUpdatedAt: pane.updatedAt,
73
+ outputHash,
74
+ title,
75
+ };
76
+
77
+ const eventChanged = pane.updatedAt > entry.eventUpdatedAt;
78
+ const outputChanged = outputHash !== undefined && entry.outputHash !== undefined && outputHash !== entry.outputHash;
79
+ const titleChanged = title !== undefined && entry.title !== undefined && title !== entry.title;
80
+ if (eventChanged) {
81
+ entry = { lastActivityAt: pane.updatedAt, eventUpdatedAt: pane.updatedAt, outputHash, title };
82
+ } else if (outputChanged || titleChanged) {
83
+ entry = { ...entry, lastActivityAt: now, outputHash, title };
84
+ } else {
85
+ entry = { ...entry, outputHash: outputHash ?? entry.outputHash, title: title ?? entry.title };
86
+ }
87
+ tracker.entries.set(pane.id, entry);
88
+
89
+ const stalled = now - entry.lastActivityAt >= stalledMs;
90
+ result.push(stalled ? { ...pane, status: 'stalled', reportedStatus: 'working', outputHash: outputHash ?? pane.outputHash } : { ...pane, outputHash: outputHash ?? pane.outputHash });
91
+ }
92
+ return result;
93
+ }
@@ -0,0 +1,18 @@
1
+ import type { TerminalBackend, TerminalTarget } from './model.js';
2
+
3
+ export interface EventSurfaceIdentity {
4
+ backend: TerminalBackend;
5
+ id: string;
6
+ }
7
+
8
+ export function canonicalSurfaceKey(surface: EventSurfaceIdentity): string {
9
+ return `${surface.backend}:${surface.id}`;
10
+ }
11
+
12
+ export function surfaceFromTarget(target: TerminalTarget): EventSurfaceIdentity {
13
+ return { backend: target.backend, id: target.id };
14
+ }
15
+
16
+ export function tmuxSurface(paneId: string): EventSurfaceIdentity {
17
+ return { backend: 'tmux', id: paneId };
18
+ }