Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions src/agent/file-watcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { watch, type FSWatcher } from 'fs';
import { access } from 'fs/promises';
import type { AgentConfig } from '../shared/types';
import { expandPath } from '../config/loader';

const STANDARD_CREDENTIAL_FILES = [
'~/.gitconfig',
'~/.claude/.credentials.json',
'~/.codex/auth.json',
'~/.codex/config.toml',
];

interface FileWatcherOptions {
config: AgentConfig;
syncCallback: () => Promise<void>;
debounceMs?: number;
}

export class FileWatcher {
private watchers: Map<string, FSWatcher> = new Map();
private config: AgentConfig;
private syncCallback: () => Promise<void>;
private debounceMs: number;
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
private pendingSync = false;

constructor(options: FileWatcherOptions) {
this.config = options.config;
this.syncCallback = options.syncCallback;
this.debounceMs = options.debounceMs ?? 500;
this.setupWatchers();
}

updateConfig(config: AgentConfig): void {
this.config = config;
this.rebuildWatchers();
}

stop(): void {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
for (const [filePath, watcher] of this.watchers) {
watcher.close();
console.log(`[file-watcher] Stopped watching: ${filePath}`);
}
this.watchers.clear();
}

private collectWatchPaths(): string[] {
const paths = new Set<string>();

for (const sourcePath of Object.values(this.config.credentials.files)) {
paths.add(expandPath(sourcePath));
}

for (const stdPath of STANDARD_CREDENTIAL_FILES) {
paths.add(expandPath(stdPath));
}

if (this.config.ssh?.global?.copy) {
for (const keyPath of this.config.ssh.global.copy) {
paths.add(expandPath(keyPath));
}
}

if (this.config.ssh?.workspaces) {
for (const wsConfig of Object.values(this.config.ssh.workspaces)) {
if (wsConfig.copy) {
for (const keyPath of wsConfig.copy) {
paths.add(expandPath(keyPath));
}
}
}
}

return Array.from(paths);
}

private async setupWatchers(): Promise<void> {
const paths = this.collectWatchPaths();

for (const filePath of paths) {
await this.watchFile(filePath);
}
}

private async watchFile(filePath: string): Promise<void> {
if (this.watchers.has(filePath)) {
return;
}

try {
await access(filePath);
} catch {
return;
}

try {
const watcher = watch(filePath, (eventType) => {
if (eventType === 'change' || eventType === 'rename') {
this.handleFileChange(filePath);
}
});

watcher.on('error', (err) => {
console.error(`[file-watcher] Error watching ${filePath}:`, err);
this.watchers.delete(filePath);
});

this.watchers.set(filePath, watcher);
console.log(`[file-watcher] Watching: ${filePath}`);
} catch (err) {
console.error(`[file-watcher] Failed to watch ${filePath}:`, err);
}
}

private handleFileChange(filePath: string): void {
console.log(`[file-watcher] Change detected: ${filePath}`);
this.scheduleSync();
}

private scheduleSync(): void {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}

this.pendingSync = true;
this.debounceTimer = setTimeout(async () => {
this.debounceTimer = null;
if (this.pendingSync) {
this.pendingSync = false;
try {
console.log('[file-watcher] Triggering sync...');
await this.syncCallback();
console.log('[file-watcher] Sync completed');
} catch (err) {
console.error('[file-watcher] Sync failed:', err);
}
}
}, this.debounceMs);
}

private rebuildWatchers(): void {
const newPaths = new Set(this.collectWatchPaths());
const currentPaths = new Set(this.watchers.keys());

for (const filePath of currentPaths) {
if (!newPaths.has(filePath)) {
const watcher = this.watchers.get(filePath);
if (watcher) {
watcher.close();
this.watchers.delete(filePath);
console.log(`[file-watcher] Stopped watching: ${filePath}`);
}
}
}

for (const filePath of newPaths) {
if (!currentPaths.has(filePath)) {
this.watchFile(filePath);
}
}
}
}
5 changes: 5 additions & 0 deletions src/agent/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export interface RouterContext {
sessionsCache: SessionsCacheManager;
modelCache: ModelCacheManager;
tailscale?: TailscaleInfo;
triggerAutoSync: () => void;
}

function mapErrorToORPC(err: unknown, defaultMessage: string): never {
Expand Down Expand Up @@ -289,6 +290,7 @@ export function createRouter(ctx: RouterContext) {
const newConfig = { ...currentConfig, credentials: input };
ctx.config.set(newConfig);
await saveAgentConfig(newConfig, ctx.configDir);
ctx.triggerAutoSync();
return input;
});

Expand All @@ -304,6 +306,7 @@ export function createRouter(ctx: RouterContext) {
const newConfig = { ...currentConfig, scripts: input };
ctx.config.set(newConfig);
await saveAgentConfig(newConfig, ctx.configDir);
ctx.triggerAutoSync();
return input;
});

Expand All @@ -319,6 +322,7 @@ export function createRouter(ctx: RouterContext) {
const newConfig = { ...currentConfig, agents: input };
ctx.config.set(newConfig);
await saveAgentConfig(newConfig, ctx.configDir);
ctx.triggerAutoSync();
return input;
});

Expand All @@ -341,6 +345,7 @@ export function createRouter(ctx: RouterContext) {
const newConfig = { ...currentConfig, ssh: input };
ctx.config.set(newConfig);
await saveAgentConfig(newConfig, ctx.configDir);
ctx.triggerAutoSync();
return input;
});

Expand Down
32 changes: 30 additions & 2 deletions src/agent/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { createRouter } from './router';
import { serveStatic } from './static';
import { SessionsCacheManager } from '../sessions/cache';
import { ModelCacheManager } from '../models/cache';
import { FileWatcher } from './file-watcher';
import {
getTailscaleStatus,
getTailscaleIdentity,
Expand Down Expand Up @@ -42,6 +43,24 @@ function createAgentServer(configDir: string, config: AgentConfig, tailscale?: T
const sessionsCache = new SessionsCacheManager(configDir);
const modelCache = new ModelCacheManager(configDir);

const syncAllRunning = async () => {
const allWorkspaces = await workspaces.list();
const running = allWorkspaces.filter((ws) => ws.status === 'running');
for (const ws of running) {
try {
await workspaces.sync(ws.name);
console.log(`[sync] Synced workspace: ${ws.name}`);
} catch (err) {
console.error(`[sync] Failed to sync ${ws.name}:`, err);
}
}
};

const fileWatcher = new FileWatcher({
config: currentConfig,
syncCallback: syncAllRunning,
});

const isWorkspaceRunning = async (name: string) => {
if (name === HOST_WORKSPACE_NAME) {
return currentConfig.allowHostAccess === true;
Expand All @@ -67,13 +86,20 @@ function createAgentServer(configDir: string, config: AgentConfig, tailscale?: T
getConfig: () => currentConfig,
});

const triggerAutoSync = () => {
syncAllRunning().catch((err) => {
console.error('[sync] Auto-sync failed:', err);
});
};

const router = createRouter({
workspaces,
config: {
get: () => currentConfig,
set: (newConfig: AgentConfig) => {
currentConfig = newConfig;
workspaces.updateConfig(newConfig);
fileWatcher.updateConfig(newConfig);
},
},
configDir,
Expand All @@ -83,6 +109,7 @@ function createAgentServer(configDir: string, config: AgentConfig, tailscale?: T
sessionsCache,
modelCache,
tailscale,
triggerAutoSync,
});

const rpcHandler = new RPCHandler(router);
Expand Down Expand Up @@ -152,7 +179,7 @@ function createAgentServer(configDir: string, config: AgentConfig, tailscale?: T
}
});

return { server, terminalServer, chatServer, opencodeServer };
return { server, terminalServer, chatServer, opencodeServer, fileWatcher };
}

export interface StartAgentOptions {
Expand Down Expand Up @@ -232,7 +259,7 @@ export async function startAgent(options: StartAgentOptions = {}): Promise<void>
}
: undefined;

const { server, terminalServer, chatServer, opencodeServer } = createAgentServer(
const { server, terminalServer, chatServer, opencodeServer, fileWatcher } = createAgentServer(
configDir,
config,
tailscaleInfo
Expand Down Expand Up @@ -272,6 +299,7 @@ export async function startAgent(options: StartAgentOptions = {}): Promise<void>

const shutdown = async () => {
console.log('[agent] Shutting down...');
fileWatcher.stop();
if (tailscaleServeActive) {
console.log('[agent] Stopping Tailscale Serve...');
await stopTailscaleServe();
Expand Down
7 changes: 7 additions & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
export interface WorkspaceCredentials {
env: Record<string, string>;
/**
* File or directory mappings from host to workspace.
* Key is destination path (in workspace), value is source path (on host).
* Paths starting with ~/ are expanded to the home directory.
* Directories are copied recursively via TAR.
* Example: { "~/.ssh/id_rsa": "~/.ssh/id_rsa", "~/.config/myapp": "~/.config/myapp" }
*/
files: Record<string, string>;
}

Expand Down
11 changes: 11 additions & 0 deletions test/helpers/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
WorkspaceInfo,
CreateWorkspaceRequest,
ApiError,
WorkspaceCredentials,
} from '../../src/shared/types';

interface ExecResult {
Expand All @@ -36,6 +37,8 @@ interface ApiClient {
deleteWorkspace(name: string): Promise<{ status: number }>;
startWorkspace(name: string): Promise<ApiResponse<WorkspaceInfo | ApiError>>;
stopWorkspace(name: string): Promise<ApiResponse<WorkspaceInfo | ApiError>>;
updateCredentials(credentials: WorkspaceCredentials): Promise<WorkspaceCredentials>;
syncWorkspace(name: string): Promise<void>;
}

export interface TestAgent {
Expand Down Expand Up @@ -183,6 +186,14 @@ export function createApiClient(baseUrl: string): ApiClient {
};
}
},

async updateCredentials(credentials: WorkspaceCredentials): Promise<WorkspaceCredentials> {
return client.config.credentials.update(credentials);
},

async syncWorkspace(name: string): Promise<void> {
await client.workspaces.sync({ name });
},
};
}

Expand Down
Loading