@agimon-ai/doompi-web 0.0.1-alpha.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/LICENSE +29 -0
- package/README.md +196 -0
- package/dist/_virtual/_rolldown/runtime.cjs +1 -0
- package/dist/bin/serve.cjs +3 -0
- package/dist/bin/serve.cjs.map +1 -0
- package/dist/bin/serve.d.cts +1 -0
- package/dist/bin/serve.d.mts +1 -0
- package/dist/bin/serve.mjs +3 -0
- package/dist/bin/serve.mjs.map +1 -0
- package/dist/bundler.cjs +1 -0
- package/dist/bundler.d.cts +2 -0
- package/dist/bundler.d.mts +2 -0
- package/dist/bundler.mjs +1 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +7 -0
- package/dist/index.d.mts +7 -0
- package/dist/index.mjs +1 -0
- package/dist/src/adapters/authRoutes.cjs +2 -0
- package/dist/src/adapters/authRoutes.cjs.map +1 -0
- package/dist/src/adapters/authRoutes.mjs +2 -0
- package/dist/src/adapters/authRoutes.mjs.map +1 -0
- package/dist/src/adapters/bundledServer.cjs +2 -0
- package/dist/src/adapters/bundledServer.cjs.map +1 -0
- package/dist/src/adapters/bundledServer.mjs +2 -0
- package/dist/src/adapters/bundledServer.mjs.map +1 -0
- package/dist/src/adapters/directoryListing.cjs +2 -0
- package/dist/src/adapters/directoryListing.cjs.map +1 -0
- package/dist/src/adapters/directoryListing.mjs +2 -0
- package/dist/src/adapters/directoryListing.mjs.map +1 -0
- package/dist/src/adapters/directorySearch.cjs +3 -0
- package/dist/src/adapters/directorySearch.cjs.map +1 -0
- package/dist/src/adapters/directorySearch.mjs +3 -0
- package/dist/src/adapters/directorySearch.mjs.map +1 -0
- package/dist/src/adapters/gitStatus.cjs +2 -0
- package/dist/src/adapters/gitStatus.cjs.map +1 -0
- package/dist/src/adapters/gitStatus.mjs +2 -0
- package/dist/src/adapters/gitStatus.mjs.map +1 -0
- package/dist/src/adapters/httpServer.cjs +2 -0
- package/dist/src/adapters/httpServer.cjs.map +1 -0
- package/dist/src/adapters/httpServer.d.cts +14 -0
- package/dist/src/adapters/httpServer.d.cts.map +1 -0
- package/dist/src/adapters/httpServer.d.mts +14 -0
- package/dist/src/adapters/httpServer.d.mts.map +1 -0
- package/dist/src/adapters/httpServer.mjs +2 -0
- package/dist/src/adapters/httpServer.mjs.map +1 -0
- package/dist/src/adapters/hubProbe.cjs +2 -0
- package/dist/src/adapters/hubProbe.cjs.map +1 -0
- package/dist/src/adapters/hubProbe.mjs +2 -0
- package/dist/src/adapters/hubProbe.mjs.map +1 -0
- package/dist/src/adapters/packageApiProxy.cjs +2 -0
- package/dist/src/adapters/packageApiProxy.cjs.map +1 -0
- package/dist/src/adapters/packageApiProxy.mjs +2 -0
- package/dist/src/adapters/packageApiProxy.mjs.map +1 -0
- package/dist/src/adapters/piHubService.cjs +2 -0
- package/dist/src/adapters/piHubService.cjs.map +1 -0
- package/dist/src/adapters/piHubService.mjs +2 -0
- package/dist/src/adapters/piHubService.mjs.map +1 -0
- package/dist/src/adapters/piWebSocketListener.cjs +2 -0
- package/dist/src/adapters/piWebSocketListener.cjs.map +1 -0
- package/dist/src/adapters/piWebSocketListener.mjs +2 -0
- package/dist/src/adapters/piWebSocketListener.mjs.map +1 -0
- package/dist/src/adapters/providerAuth.cjs +2 -0
- package/dist/src/adapters/providerAuth.cjs.map +1 -0
- package/dist/src/adapters/providerAuth.mjs +2 -0
- package/dist/src/adapters/providerAuth.mjs.map +1 -0
- package/dist/src/adapters/registryWatcher.cjs +2 -0
- package/dist/src/adapters/registryWatcher.cjs.map +1 -0
- package/dist/src/adapters/registryWatcher.mjs +2 -0
- package/dist/src/adapters/registryWatcher.mjs.map +1 -0
- package/dist/src/adapters/serverSpawner.cjs +2 -0
- package/dist/src/adapters/serverSpawner.cjs.map +1 -0
- package/dist/src/adapters/serverSpawner.mjs +2 -0
- package/dist/src/adapters/serverSpawner.mjs.map +1 -0
- package/dist/src/adapters/sessionFiles.cjs +3 -0
- package/dist/src/adapters/sessionFiles.cjs.map +1 -0
- package/dist/src/adapters/sessionFiles.mjs +3 -0
- package/dist/src/adapters/sessionFiles.mjs.map +1 -0
- package/dist/src/adapters/sessionHub.cjs +2 -0
- package/dist/src/adapters/sessionHub.cjs.map +1 -0
- package/dist/src/adapters/sessionHub.mjs +2 -0
- package/dist/src/adapters/sessionHub.mjs.map +1 -0
- package/dist/src/adapters/sessionSocketClient.cjs +2 -0
- package/dist/src/adapters/sessionSocketClient.cjs.map +1 -0
- package/dist/src/adapters/sessionSocketClient.mjs +2 -0
- package/dist/src/adapters/sessionSocketClient.mjs.map +1 -0
- package/dist/src/adapters/syncGuard.cjs +2 -0
- package/dist/src/adapters/syncGuard.cjs.map +1 -0
- package/dist/src/adapters/syncGuard.mjs +2 -0
- package/dist/src/adapters/syncGuard.mjs.map +1 -0
- package/dist/src/adapters/threadJournals.cjs +2 -0
- package/dist/src/adapters/threadJournals.cjs.map +1 -0
- package/dist/src/adapters/threadJournals.mjs +2 -0
- package/dist/src/adapters/threadJournals.mjs.map +1 -0
- package/dist/src/adapters/webBundler.cjs +2 -0
- package/dist/src/adapters/webBundler.cjs.map +1 -0
- package/dist/src/adapters/webBundler.d.cts +28 -0
- package/dist/src/adapters/webBundler.d.cts.map +1 -0
- package/dist/src/adapters/webBundler.d.mts +28 -0
- package/dist/src/adapters/webBundler.d.mts.map +1 -0
- package/dist/src/adapters/webBundler.mjs +2 -0
- package/dist/src/adapters/webBundler.mjs.map +1 -0
- package/dist/src/adapters/webHubPluginLoader.cjs +2 -0
- package/dist/src/adapters/webHubPluginLoader.cjs.map +1 -0
- package/dist/src/adapters/webHubPluginLoader.mjs +2 -0
- package/dist/src/adapters/webHubPluginLoader.mjs.map +1 -0
- package/dist/src/adapters/webHubPlugins.generated.cjs +2 -0
- package/dist/src/adapters/webHubPlugins.generated.cjs.map +1 -0
- package/dist/src/adapters/webHubPlugins.generated.mjs +2 -0
- package/dist/src/adapters/webHubPlugins.generated.mjs.map +1 -0
- package/dist/src/adapters/webPluginGenerate.cjs +4 -0
- package/dist/src/adapters/webPluginGenerate.cjs.map +1 -0
- package/dist/src/adapters/webPluginGenerate.mjs +4 -0
- package/dist/src/adapters/webPluginGenerate.mjs.map +1 -0
- package/dist/src/adapters/webPluginScan.cjs +2 -0
- package/dist/src/adapters/webPluginScan.cjs.map +1 -0
- package/dist/src/adapters/webPluginScan.mjs +2 -0
- package/dist/src/adapters/webPluginScan.mjs.map +1 -0
- package/dist/src/adapters/webPluginVite.cjs +2 -0
- package/dist/src/adapters/webPluginVite.cjs.map +1 -0
- package/dist/src/adapters/webPluginVite.mjs +2 -0
- package/dist/src/adapters/webPluginVite.mjs.map +1 -0
- package/dist/src/services/directoryMatch.cjs +2 -0
- package/dist/src/services/directoryMatch.cjs.map +1 -0
- package/dist/src/services/directoryMatch.mjs +2 -0
- package/dist/src/services/directoryMatch.mjs.map +1 -0
- package/dist/src/services/fileMatch.cjs +2 -0
- package/dist/src/services/fileMatch.cjs.map +1 -0
- package/dist/src/services/fileMatch.mjs +2 -0
- package/dist/src/services/fileMatch.mjs.map +1 -0
- package/dist/src/services/fileMedia.cjs +2 -0
- package/dist/src/services/fileMedia.cjs.map +1 -0
- package/dist/src/services/fileMedia.mjs +2 -0
- package/dist/src/services/fileMedia.mjs.map +1 -0
- package/dist/src/services/frameRing.cjs +2 -0
- package/dist/src/services/frameRing.cjs.map +1 -0
- package/dist/src/services/frameRing.mjs +2 -0
- package/dist/src/services/frameRing.mjs.map +1 -0
- package/dist/src/services/journalTail.cjs +3 -0
- package/dist/src/services/journalTail.cjs.map +1 -0
- package/dist/src/services/journalTail.mjs +3 -0
- package/dist/src/services/journalTail.mjs.map +1 -0
- package/dist/src/services/loginFlow.cjs +2 -0
- package/dist/src/services/loginFlow.cjs.map +1 -0
- package/dist/src/services/loginFlow.mjs +2 -0
- package/dist/src/services/loginFlow.mjs.map +1 -0
- package/dist/src/services/registryStore.cjs +2 -0
- package/dist/src/services/registryStore.cjs.map +1 -0
- package/dist/src/services/registryStore.mjs +2 -0
- package/dist/src/services/registryStore.mjs.map +1 -0
- package/dist/src/services/retryPolicy.cjs +2 -0
- package/dist/src/services/retryPolicy.cjs.map +1 -0
- package/dist/src/services/retryPolicy.mjs +2 -0
- package/dist/src/services/retryPolicy.mjs.map +1 -0
- package/dist/src/services/serveOptions.cjs +2 -0
- package/dist/src/services/serveOptions.cjs.map +1 -0
- package/dist/src/services/serveOptions.d.cts +21 -0
- package/dist/src/services/serveOptions.d.cts.map +1 -0
- package/dist/src/services/serveOptions.d.mts +21 -0
- package/dist/src/services/serveOptions.d.mts.map +1 -0
- package/dist/src/services/serveOptions.mjs +2 -0
- package/dist/src/services/serveOptions.mjs.map +1 -0
- package/dist/src/services/sessionFraming.cjs +4 -0
- package/dist/src/services/sessionFraming.cjs.map +1 -0
- package/dist/src/services/sessionFraming.mjs +4 -0
- package/dist/src/services/sessionFraming.mjs.map +1 -0
- package/dist/src/services/sessionPresence.cjs +2 -0
- package/dist/src/services/sessionPresence.cjs.map +1 -0
- package/dist/src/services/sessionPresence.mjs +2 -0
- package/dist/src/services/sessionPresence.mjs.map +1 -0
- package/dist/src/services/staticAssets.cjs +2 -0
- package/dist/src/services/staticAssets.cjs.map +1 -0
- package/dist/src/services/staticAssets.mjs +2 -0
- package/dist/src/services/staticAssets.mjs.map +1 -0
- package/dist/src/services/webDevRoots.cjs +2 -0
- package/dist/src/services/webDevRoots.cjs.map +1 -0
- package/dist/src/services/webDevRoots.mjs +2 -0
- package/dist/src/services/webDevRoots.mjs.map +1 -0
- package/dist/src/services/webPluginManifest.cjs +2 -0
- package/dist/src/services/webPluginManifest.cjs.map +1 -0
- package/dist/src/services/webPluginManifest.mjs +2 -0
- package/dist/src/services/webPluginManifest.mjs.map +1 -0
- package/dist/src/types/auth.cjs +2 -0
- package/dist/src/types/auth.cjs.map +1 -0
- package/dist/src/types/auth.d.cts +94 -0
- package/dist/src/types/auth.d.cts.map +1 -0
- package/dist/src/types/auth.d.mts +94 -0
- package/dist/src/types/auth.d.mts.map +1 -0
- package/dist/src/types/auth.mjs +2 -0
- package/dist/src/types/auth.mjs.map +1 -0
- package/dist/src/types/bridge.d.cts +31 -0
- package/dist/src/types/bridge.d.cts.map +1 -0
- package/dist/src/types/bridge.d.mts +31 -0
- package/dist/src/types/bridge.d.mts.map +1 -0
- package/dist/src/types/hub.cjs +2 -0
- package/dist/src/types/hub.cjs.map +1 -0
- package/dist/src/types/hub.d.cts +58 -0
- package/dist/src/types/hub.d.cts.map +1 -0
- package/dist/src/types/hub.d.mts +58 -0
- package/dist/src/types/hub.d.mts.map +1 -0
- package/dist/src/types/hub.mjs +2 -0
- package/dist/src/types/hub.mjs.map +1 -0
- package/dist/src/types/media.cjs +2 -0
- package/dist/src/types/media.cjs.map +1 -0
- package/dist/src/types/media.mjs +2 -0
- package/dist/src/types/media.mjs.map +1 -0
- package/dist/src/types/registry.cjs +1 -0
- package/dist/src/types/registry.d.cts +2 -0
- package/dist/src/types/registry.d.mts +2 -0
- package/dist/src/types/registry.mjs +1 -0
- package/dist/src/types/session.cjs +2 -0
- package/dist/src/types/session.cjs.map +1 -0
- package/dist/src/types/session.d.cts +24 -0
- package/dist/src/types/session.d.cts.map +1 -0
- package/dist/src/types/session.d.mts +24 -0
- package/dist/src/types/session.d.mts.map +1 -0
- package/dist/src/types/session.mjs +2 -0
- package/dist/src/types/session.mjs.map +1 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/index-Cg52ngKv.css +2 -0
- package/dist/web/assets/index-DQO7NTIP.js +90 -0
- package/dist/web/assets/index-DQO7NTIP.js.map +1 -0
- package/dist/web/favicon-16.png +0 -0
- package/dist/web/favicon-32.png +0 -0
- package/dist/web/favicon.ico +0 -0
- package/dist/web/favicon.svg +16 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/index.html +17 -0
- package/llms.txt +8 -0
- package/package.json +106 -0
- package/src/adapters/authRoutes.ts +95 -0
- package/src/adapters/bundledServer.ts +64 -0
- package/src/adapters/directoryListing.ts +127 -0
- package/src/adapters/directorySearch.ts +124 -0
- package/src/adapters/gitStatus.ts +27 -0
- package/src/adapters/httpServer.ts +556 -0
- package/src/adapters/hubProbe.ts +27 -0
- package/src/adapters/packageApiProxy.ts +60 -0
- package/src/adapters/piHubService.ts +129 -0
- package/src/adapters/piWebSocketListener.ts +98 -0
- package/src/adapters/providerAuth.ts +181 -0
- package/src/adapters/registryWatcher.ts +112 -0
- package/src/adapters/serverSpawner.ts +181 -0
- package/src/adapters/sessionFiles.ts +99 -0
- package/src/adapters/sessionHub.ts +567 -0
- package/src/adapters/sessionSocketClient.ts +105 -0
- package/src/adapters/syncGuard.ts +119 -0
- package/src/adapters/threadJournals.ts +184 -0
- package/src/adapters/webBundler.ts +83 -0
- package/src/adapters/webHubPluginLoader.ts +67 -0
- package/src/adapters/webHubPlugins.generated.ts +4 -0
- package/src/adapters/webPluginGenerate.ts +172 -0
- package/src/adapters/webPluginScan.ts +62 -0
- package/src/adapters/webPluginVite.ts +51 -0
- package/src/bin/serve.ts +64 -0
- package/src/exports/bundler.ts +1 -0
- package/src/exports/index.ts +9 -0
- package/src/services/directoryMatch.ts +71 -0
- package/src/services/fileMatch.ts +37 -0
- package/src/services/fileMedia.ts +27 -0
- package/src/services/frameRing.ts +33 -0
- package/src/services/journalTail.ts +47 -0
- package/src/services/loginFlow.ts +144 -0
- package/src/services/registryStore.ts +83 -0
- package/src/services/retryPolicy.ts +15 -0
- package/src/services/serveOptions.ts +63 -0
- package/src/services/sessionFraming.ts +32 -0
- package/src/services/sessionPresence.ts +131 -0
- package/src/services/staticAssets.ts +37 -0
- package/src/services/webDevRoots.ts +36 -0
- package/src/services/webPluginManifest.ts +149 -0
- package/src/types/auth.ts +118 -0
- package/src/types/bridge.ts +41 -0
- package/src/types/hub.ts +290 -0
- package/src/types/media.ts +38 -0
- package/src/types/registry.ts +2 -0
- package/src/types/session.ts +36 -0
- package/src/web/app/Providers.tsx +46 -0
- package/src/web/app/protocolRuntime.ts +106 -0
- package/src/web/app/sessionRuntime.ts +189 -0
- package/src/web/app/webPlugins.generated.ts +4 -0
- package/src/web/components/MascotMark.tsx +46 -0
- package/src/web/components/PluginSurface.tsx +10 -0
- package/src/web/components/ToolRendererBoundary.tsx +32 -0
- package/src/web/features/activity/ActivityDock.tsx +125 -0
- package/src/web/features/connection/RefusedCard.tsx +63 -0
- package/src/web/features/dialogs/DialogOverlay.tsx +176 -0
- package/src/web/features/leader/CommandPalette.tsx +386 -0
- package/src/web/features/selection/SelectionBar.tsx +529 -0
- package/src/web/features/session/Composer.tsx +368 -0
- package/src/web/features/session/MentionPreviews.tsx +58 -0
- package/src/web/features/session/ThreadView.tsx +35 -0
- package/src/web/features/session/Timeline.tsx +382 -0
- package/src/web/features/session/ToolCard.tsx +79 -0
- package/src/web/features/sessions/NewSessionDialog.tsx +222 -0
- package/src/web/features/sessions/SessionRail.tsx +411 -0
- package/src/web/features/settings/AppearanceSettings.tsx +88 -0
- package/src/web/features/settings/LoginFlowDialog.tsx +221 -0
- package/src/web/features/settings/PluginSettings.tsx +92 -0
- package/src/web/features/settings/ProviderSettings.tsx +238 -0
- package/src/web/features/settings/SettingsMenu.tsx +32 -0
- package/src/web/features/status/TopBar.tsx +208 -0
- package/src/web/index.html +16 -0
- package/src/web/lib/authApi.ts +90 -0
- package/src/web/lib/commands.ts +61 -0
- package/src/web/lib/composition.ts +225 -0
- package/src/web/lib/fileMentions.ts +32 -0
- package/src/web/lib/hubApi.ts +86 -0
- package/src/web/lib/leaderTree.ts +159 -0
- package/src/web/lib/piTransport.ts +61 -0
- package/src/web/lib/pluginRegistry.ts +488 -0
- package/src/web/lib/pluginSlotProps.ts +50 -0
- package/src/web/lib/promptFocus.ts +25 -0
- package/src/web/lib/protocolTimeline.ts +73 -0
- package/src/web/lib/sessionModel.ts +673 -0
- package/src/web/lib/sessionSummary.ts +58 -0
- package/src/web/lib/settingsSections.ts +21 -0
- package/src/web/lib/statusLine.ts +90 -0
- package/src/web/lib/threadRenderer.ts +25 -0
- package/src/web/lib/toolMessageProps.ts +21 -0
- package/src/web/lib/transport.ts +30 -0
- package/src/web/lib/wsClient.ts +73 -0
- package/src/web/main.tsx +17 -0
- package/src/web/public/apple-touch-icon.png +0 -0
- package/src/web/public/favicon-16.png +0 -0
- package/src/web/public/favicon-32.png +0 -0
- package/src/web/public/favicon.ico +0 -0
- package/src/web/public/favicon.svg +16 -0
- package/src/web/public/icon-512.png +0 -0
- package/src/web/routes/CockpitPage.tsx +98 -0
- package/src/web/routes/SettingsPage.tsx +60 -0
- package/src/web/routes/routeTree.tsx +49 -0
- package/src/web/stores/menuStore.ts +78 -0
- package/src/web/stores/paletteStore.ts +25 -0
- package/src/web/stores/sessionStore.ts +303 -0
- package/src/web/stores/sessionsStore.ts +155 -0
- package/src/web/stores/themeStore.ts +52 -0
- package/src/web/stores/threadStore.ts +68 -0
- package/src/web/stores/transientTabsStore.ts +63 -0
- package/src/web/stores/uiStore.ts +28 -0
- package/src/web/stores/useOpenTab.ts +19 -0
- package/src/web/stores/usePluginSlotProps.ts +22 -0
- package/src/web/styles/app.css +15 -0
- package/src/web/styles/webPluginSources.generated.css +1 -0
- package/src/web/vite-env.d.ts +1 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { PiClient } from '@earendil-works/pi-client';
|
|
2
|
+
import { createUnixTransportFactory } from '@earendil-works/pi-client/unix';
|
|
3
|
+
import type { SessionLease } from '@earendil-works/pi-client';
|
|
4
|
+
import type { ModelMetadata, SessionMetadata } from '@earendil-works/pi-protocol';
|
|
5
|
+
import { PiServerError } from '@earendil-works/pi-server';
|
|
6
|
+
import type { CreateSessionOptions, PiServerService, PiSessionRuntime } from '@earendil-works/pi-server';
|
|
7
|
+
import type { SessionRecord } from '../types/registry.ts';
|
|
8
|
+
import type { SpawnSessionInput, SpawnOutcome } from './serverSpawner.ts';
|
|
9
|
+
|
|
10
|
+
/** How long a created session has to publish its record before the client is told it failed. */
|
|
11
|
+
const CREATE_TIMEOUT_MS = 10_000;
|
|
12
|
+
const CREATE_POLL_MS = 100;
|
|
13
|
+
|
|
14
|
+
export interface PiHubServiceOptions {
|
|
15
|
+
/** Every session the registry currently lists. */
|
|
16
|
+
records(): readonly SessionRecord[];
|
|
17
|
+
spawn(input: SpawnSessionInput): Promise<SpawnOutcome>;
|
|
18
|
+
onNotice?: (message: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toMetadata(record: SessionRecord): SessionMetadata {
|
|
22
|
+
const createdAt = Date.parse(record.createdAt);
|
|
23
|
+
return {
|
|
24
|
+
id: record.id,
|
|
25
|
+
createdAt: Number.isFinite(createdAt) ? createdAt : 0,
|
|
26
|
+
sessionName: record.name,
|
|
27
|
+
cwd: record.cwd,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Presents one live lease on a session's own server as a runtime.
|
|
33
|
+
*
|
|
34
|
+
* The hub owns no session state of its own here. It holds a connection open
|
|
35
|
+
* and forwards, so the snapshot a browser reads is the one the session server
|
|
36
|
+
* published rather than a copy the hub reduced for itself.
|
|
37
|
+
*/
|
|
38
|
+
function leaseRuntime(client: PiClient, lease: SessionLease): PiSessionRuntime {
|
|
39
|
+
const snapshot = () => {
|
|
40
|
+
const current = lease.snapshot;
|
|
41
|
+
if (!current) throw new PiServerError('not_found', 'The session has not published a snapshot yet');
|
|
42
|
+
return current;
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
snapshot,
|
|
46
|
+
getPhase: () => snapshot().phase,
|
|
47
|
+
prompt: async (input) => void (await lease.prompt(input.text)),
|
|
48
|
+
steer: async (input) => void (await lease.steer(input.text)),
|
|
49
|
+
abort: async () => void (await lease.abort()),
|
|
50
|
+
setModel: async (model) => void (await lease.setModel(model)),
|
|
51
|
+
setThinking: async (level) => void (await lease.setThinking(level)),
|
|
52
|
+
subscribe(listener) {
|
|
53
|
+
const stopSnapshots = lease.subscribe(() => listener({ type: 'snapshot' }));
|
|
54
|
+
const stopEvents = lease.onEvent((event) => {
|
|
55
|
+
if (event.type === 'session_progress') listener({ type: 'progress', progress: event.progress });
|
|
56
|
+
});
|
|
57
|
+
return () => {
|
|
58
|
+
stopSnapshots();
|
|
59
|
+
stopEvents();
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
async dispose() {
|
|
63
|
+
// The upstream connection belongs to this hold, so both go together and
|
|
64
|
+
// the session server sees the detach it is waiting for.
|
|
65
|
+
await lease.dispose().catch(() => undefined);
|
|
66
|
+
await client.dispose().catch(() => undefined);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The cockpit's own protocol server, backed by every running session.
|
|
73
|
+
*
|
|
74
|
+
* Each session already serves the protocol on its own socket, so the hub does
|
|
75
|
+
* not reinterpret any of it: it lists what the registry knows and dials the
|
|
76
|
+
* session a browser asks for. That keeps one vocabulary from the agent all the
|
|
77
|
+
* way to the page.
|
|
78
|
+
*/
|
|
79
|
+
export function createPiHubService(options: PiHubServiceOptions): PiServerService {
|
|
80
|
+
const find = (sessionId: string): SessionRecord => {
|
|
81
|
+
const record = options.records().find((candidate) => candidate.id === sessionId);
|
|
82
|
+
if (!record) throw new PiServerError('not_found', `No session ${sessionId}`);
|
|
83
|
+
return record;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const open = async (record: SessionRecord): Promise<PiSessionRuntime> => {
|
|
87
|
+
if (!record.protocolSocketPath) {
|
|
88
|
+
throw new PiServerError('not_implemented', `Session ${record.id} predates the protocol socket`);
|
|
89
|
+
}
|
|
90
|
+
const client = new PiClient({
|
|
91
|
+
transportFactory: createUnixTransportFactory({ path: record.protocolSocketPath }),
|
|
92
|
+
onListenerError: (error) => options.onNotice?.(`session ${record.id} listener error: ${error.message}`),
|
|
93
|
+
});
|
|
94
|
+
await client.connect();
|
|
95
|
+
try {
|
|
96
|
+
return leaseRuntime(client, await client.attachSession(record.id));
|
|
97
|
+
} catch (error) {
|
|
98
|
+
await client.dispose().catch(() => undefined);
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
listSessions: async () => options.records().map(toMetadata),
|
|
105
|
+
listModels: async (): Promise<ModelMetadata[]> => [],
|
|
106
|
+
|
|
107
|
+
async createSession(create: CreateSessionOptions) {
|
|
108
|
+
if (!create.cwd) throw new PiServerError('invalid_request', 'A new session needs a working directory');
|
|
109
|
+
const outcome = await options.spawn({ cwd: create.cwd, ...(create.name ? { name: create.name } : {}) });
|
|
110
|
+
if (!outcome.ok) {
|
|
111
|
+
throw new PiServerError(outcome.code === 'invalid_request' ? 'invalid_request' : 'not_found', outcome.error);
|
|
112
|
+
}
|
|
113
|
+
// The spawner returns once the record lands, but the session's protocol
|
|
114
|
+
// socket is published a moment later; waiting here spares the caller a
|
|
115
|
+
// retry loop it has no way to bound.
|
|
116
|
+
const deadline = Date.now() + CREATE_TIMEOUT_MS;
|
|
117
|
+
for (;;) {
|
|
118
|
+
const record = options.records().find((candidate) => candidate.id === outcome.sessionId);
|
|
119
|
+
if (record?.protocolSocketPath) return open(record);
|
|
120
|
+
if (Date.now() >= deadline) {
|
|
121
|
+
throw new PiServerError('not_found', `Session ${outcome.sessionId} never published a protocol socket`);
|
|
122
|
+
}
|
|
123
|
+
await new Promise((resolve) => setTimeout(resolve, CREATE_POLL_MS));
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
openSession: async (sessionId: string) => open(find(sessionId)),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { PiServerListener } from '@earendil-works/pi-server';
|
|
2
|
+
|
|
3
|
+
// pi-server names these in PiServerListener's signature but does not export
|
|
4
|
+
// them from its root, so they are recovered from the type that does.
|
|
5
|
+
type ByteConnectionAcceptor = Parameters<PiServerListener['start']>[0];
|
|
6
|
+
type ByteConnection = Parameters<ByteConnectionAcceptor>[0];
|
|
7
|
+
type ByteConnectionHandler = ReturnType<ByteConnectionAcceptor>;
|
|
8
|
+
|
|
9
|
+
/** The minimum of a WebSocket this listener drives, so a test needs no real socket. */
|
|
10
|
+
export interface ListenerSocket {
|
|
11
|
+
send(data: ArrayBufferLike | Uint8Array): void;
|
|
12
|
+
close(): void;
|
|
13
|
+
readonly readyState?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const OPEN = 1;
|
|
17
|
+
|
|
18
|
+
export interface PiWebSocketListener extends PiServerListener {
|
|
19
|
+
/**
|
|
20
|
+
* Hands an already-upgraded socket to the server.
|
|
21
|
+
*
|
|
22
|
+
* The transport authenticates during the HTTP upgrade, so a socket only
|
|
23
|
+
* reaches here once the request that opened it was allowed.
|
|
24
|
+
*/
|
|
25
|
+
accept(socket: ListenerSocket): ByteConnectionHandler | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Feeds PiServer the browser's connections.
|
|
30
|
+
*
|
|
31
|
+
* Pi ships only a Unix listener, where the file mode is the access control.
|
|
32
|
+
* A browser cannot open one, so the cockpit terminates the protocol over a
|
|
33
|
+
* WebSocket instead and keeps the same contract: bytes in order, closure
|
|
34
|
+
* reported once, and no protocol knowledge in the transport itself.
|
|
35
|
+
*/
|
|
36
|
+
export function createPiWebSocketListener(options: { onError?: (error: Error) => void } = {}): PiWebSocketListener {
|
|
37
|
+
let acceptor: ByteConnectionAcceptor | undefined;
|
|
38
|
+
const open = new Set<ListenerSocket>();
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
address: 'websocket',
|
|
42
|
+
|
|
43
|
+
async start(accept) {
|
|
44
|
+
acceptor = accept;
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
async close() {
|
|
48
|
+
acceptor = undefined;
|
|
49
|
+
for (const socket of open) {
|
|
50
|
+
try {
|
|
51
|
+
socket.close();
|
|
52
|
+
} catch (error) {
|
|
53
|
+
options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
open.clear();
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
accept(socket) {
|
|
60
|
+
// A socket that arrives before start, or after close, has no server to
|
|
61
|
+
// talk to. Closing it is the honest answer.
|
|
62
|
+
if (!acceptor) {
|
|
63
|
+
socket.close();
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
open.add(socket);
|
|
67
|
+
let closed = false;
|
|
68
|
+
const connection: ByteConnection = {
|
|
69
|
+
get closed() {
|
|
70
|
+
return closed || (socket.readyState !== undefined && socket.readyState !== OPEN);
|
|
71
|
+
},
|
|
72
|
+
async send(chunk) {
|
|
73
|
+
socket.send(chunk);
|
|
74
|
+
},
|
|
75
|
+
close(finalChunk) {
|
|
76
|
+
if (finalChunk) socket.send(finalChunk);
|
|
77
|
+
closed = true;
|
|
78
|
+
open.delete(socket);
|
|
79
|
+
socket.close();
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
const handler = acceptor(connection);
|
|
83
|
+
return {
|
|
84
|
+
onData: (chunk) => handler.onData(chunk),
|
|
85
|
+
onClose: () => {
|
|
86
|
+
closed = true;
|
|
87
|
+
open.delete(socket);
|
|
88
|
+
handler.onClose();
|
|
89
|
+
},
|
|
90
|
+
onError: (error) => {
|
|
91
|
+
closed = true;
|
|
92
|
+
open.delete(socket);
|
|
93
|
+
handler.onError(error);
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { createLoginFlow, type LoginFlow } from '../services/loginFlow.ts';
|
|
3
|
+
import type {
|
|
4
|
+
AuthMethodType,
|
|
5
|
+
AuthRuntime,
|
|
6
|
+
AuthRuntimeProvider,
|
|
7
|
+
LoginFlowSnapshot,
|
|
8
|
+
ProviderAuthMethod,
|
|
9
|
+
ProviderAuthSummary,
|
|
10
|
+
} from '../types/auth.ts';
|
|
11
|
+
|
|
12
|
+
/** Matches the timeout Pi's own /logout gives the credential store. */
|
|
13
|
+
const LOGOUT_TIMEOUT_MS = 15_000;
|
|
14
|
+
|
|
15
|
+
export type StartLoginOutcome =
|
|
16
|
+
| { ok: true; flow: LoginFlowSnapshot }
|
|
17
|
+
| { ok: false; code: 'unknown_provider' | 'unsupported_method' | 'busy'; error: string };
|
|
18
|
+
|
|
19
|
+
export type LogoutOutcome = { ok: true } | { ok: false; code: 'unknown_provider' | 'runtime'; error: string };
|
|
20
|
+
|
|
21
|
+
export type AnswerOutcome = 'answered' | 'unknown_flow' | 'not_waiting';
|
|
22
|
+
|
|
23
|
+
/** Provider credentials as the settings page sees them, backed by one Pi runtime. */
|
|
24
|
+
export interface ProviderAuth {
|
|
25
|
+
/** Every provider Pi knows, with its current auth state; re-read from disk each call. */
|
|
26
|
+
listProviders(): Promise<ProviderAuthSummary[]>;
|
|
27
|
+
startLogin(providerId: string, type: AuthMethodType): Promise<StartLoginOutcome>;
|
|
28
|
+
getLogin(flowId: string): LoginFlowSnapshot | undefined;
|
|
29
|
+
answerLogin(flowId: string, promptId: string, value: string): AnswerOutcome;
|
|
30
|
+
/** Cancels the flow and returns how it stands, or undefined for an unknown flow. */
|
|
31
|
+
cancelLogin(flowId: string): LoginFlowSnapshot | undefined;
|
|
32
|
+
logout(providerId: string): Promise<LogoutOutcome>;
|
|
33
|
+
/** Abandons every running flow; the hub is shutting down. */
|
|
34
|
+
close(): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ProviderAuthOptions {
|
|
38
|
+
/** Test seam over Pi's ModelRuntime; the default loads the real one over auth.json. */
|
|
39
|
+
runtime?: () => Promise<AuthRuntime>;
|
|
40
|
+
onNotice?: (message: string) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function describe(error: unknown): string {
|
|
44
|
+
return error instanceof Error ? error.message : String(error);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Pi's runtime, imported on first use: the hub should come up without paying
|
|
49
|
+
* for the provider catalog, and a machine without Pi installed still serves
|
|
50
|
+
* sessions, just not the providers page.
|
|
51
|
+
*/
|
|
52
|
+
async function loadPiRuntime(): Promise<AuthRuntime> {
|
|
53
|
+
const { ModelRuntime } = await import('@earendil-works/pi-coding-agent');
|
|
54
|
+
return ModelRuntime.create();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The methods a page can start, in the order the TUI's selector lists them. */
|
|
58
|
+
function methodsOf(provider: AuthRuntimeProvider): ProviderAuthMethod[] {
|
|
59
|
+
const methods: ProviderAuthMethod[] = [];
|
|
60
|
+
const { oauth, apiKey } = provider.auth;
|
|
61
|
+
if (oauth) methods.push({ type: 'oauth', label: oauth.loginLabel ?? oauth.name });
|
|
62
|
+
if (apiKey?.login) methods.push({ type: 'api_key', label: apiKey.name });
|
|
63
|
+
return methods;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Mirrors the status the TUI's login selector shows beside each provider. */
|
|
67
|
+
function summarize(runtime: AuthRuntime, provider: AuthRuntimeProvider): ProviderAuthSummary {
|
|
68
|
+
const summary: ProviderAuthSummary = { id: provider.id, name: provider.name, methods: methodsOf(provider) };
|
|
69
|
+
const status = runtime.getProviderAuthStatus(provider.id);
|
|
70
|
+
if (status.configured) {
|
|
71
|
+
summary.authenticated = {
|
|
72
|
+
type: runtime.isUsingOAuth(provider.id) ? 'oauth' : 'api_key',
|
|
73
|
+
source: status.label ?? status.source,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return summary;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createProviderAuth(options: ProviderAuthOptions = {}): ProviderAuth {
|
|
80
|
+
const notice = options.onNotice ?? ((): void => {});
|
|
81
|
+
const load = options.runtime ?? loadPiRuntime;
|
|
82
|
+
const flows = new Map<string, LoginFlow>();
|
|
83
|
+
let loading: Promise<AuthRuntime> | undefined;
|
|
84
|
+
|
|
85
|
+
// A failed load is forgotten so the next request tries again rather than
|
|
86
|
+
// pinning the page on a startup error.
|
|
87
|
+
const runtime = (): Promise<AuthRuntime> => {
|
|
88
|
+
loading ??= load().catch((error: unknown) => {
|
|
89
|
+
loading = undefined;
|
|
90
|
+
throw error;
|
|
91
|
+
});
|
|
92
|
+
return loading;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const findProvider = (current: AuthRuntime, providerId: string): AuthRuntimeProvider | undefined =>
|
|
96
|
+
current.getProviders().find((provider) => provider.id === providerId);
|
|
97
|
+
|
|
98
|
+
const runningFor = (providerId: string): LoginFlow | undefined =>
|
|
99
|
+
[...flows.values()].find((flow) => flow.providerId === providerId && flow.snapshot().status === 'running');
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
async listProviders() {
|
|
103
|
+
const current = await runtime();
|
|
104
|
+
// Another client may have stored a credential since the last look (a
|
|
105
|
+
// TUI /login, a hand-edited auth.json); the stale snapshot still lists
|
|
106
|
+
// on a failed refresh, which the notice explains.
|
|
107
|
+
try {
|
|
108
|
+
await current.refresh({ allowNetwork: false });
|
|
109
|
+
} catch (error) {
|
|
110
|
+
notice(`provider auth refresh failed: ${describe(error)}`);
|
|
111
|
+
}
|
|
112
|
+
return current
|
|
113
|
+
.getProviders()
|
|
114
|
+
.map((provider) => summarize(current, provider))
|
|
115
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async startLogin(providerId, type) {
|
|
119
|
+
const current = await runtime();
|
|
120
|
+
const provider = findProvider(current, providerId);
|
|
121
|
+
if (!provider) return { ok: false, code: 'unknown_provider', error: `Unknown provider: ${providerId}` };
|
|
122
|
+
if (!methodsOf(provider).some((method) => method.type === type)) {
|
|
123
|
+
return { ok: false, code: 'unsupported_method', error: `${provider.name} has no ${type} login.` };
|
|
124
|
+
}
|
|
125
|
+
if (runningFor(providerId)) {
|
|
126
|
+
return { ok: false, code: 'busy', error: `A login for ${provider.name} is already in progress.` };
|
|
127
|
+
}
|
|
128
|
+
// Finished flows live until the next start: long enough for the page to
|
|
129
|
+
// read how they ended, short enough that the map never grows.
|
|
130
|
+
for (const [id, flow] of flows) if (flow.snapshot().status !== 'running') flows.delete(id);
|
|
131
|
+
|
|
132
|
+
const flow = createLoginFlow({ id: crypto.randomUUID(), providerId, providerName: provider.name, type });
|
|
133
|
+
flows.set(flow.id, flow);
|
|
134
|
+
current.login(providerId, type, flow.interaction).then(
|
|
135
|
+
() => {
|
|
136
|
+
flow.settle({ ok: true });
|
|
137
|
+
notice(`signed in to ${provider.name} (${type})`);
|
|
138
|
+
},
|
|
139
|
+
(error: unknown) => {
|
|
140
|
+
flow.settle({ ok: false, error: describe(error) });
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
return { ok: true, flow: flow.snapshot() };
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
getLogin(flowId) {
|
|
147
|
+
return flows.get(flowId)?.snapshot();
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
answerLogin(flowId, promptId, value) {
|
|
151
|
+
const flow = flows.get(flowId);
|
|
152
|
+
if (!flow) return 'unknown_flow';
|
|
153
|
+
return flow.answer(promptId, value) ? 'answered' : 'not_waiting';
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
cancelLogin(flowId) {
|
|
157
|
+
const flow = flows.get(flowId);
|
|
158
|
+
if (!flow) return undefined;
|
|
159
|
+
flow.cancel();
|
|
160
|
+
return flow.snapshot();
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
async logout(providerId) {
|
|
164
|
+
const current = await runtime();
|
|
165
|
+
const provider = findProvider(current, providerId);
|
|
166
|
+
if (!provider) return { ok: false, code: 'unknown_provider', error: `Unknown provider: ${providerId}` };
|
|
167
|
+
try {
|
|
168
|
+
await current.logout(providerId, { signal: AbortSignal.timeout(LOGOUT_TIMEOUT_MS) });
|
|
169
|
+
} catch (error) {
|
|
170
|
+
return { ok: false, code: 'runtime', error: describe(error) };
|
|
171
|
+
}
|
|
172
|
+
notice(`signed out of ${provider.name}`);
|
|
173
|
+
return { ok: true };
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
close() {
|
|
177
|
+
for (const flow of flows.values()) flow.cancel();
|
|
178
|
+
flows.clear();
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isRecordFileName, parseSessionRecord, sessionRecordsDir } from '../services/registryStore.ts';
|
|
4
|
+
import type { SessionRecord } from '../types/registry.ts';
|
|
5
|
+
|
|
6
|
+
const POLL_MS = 2000;
|
|
7
|
+
const DEBOUNCE_MS = 50;
|
|
8
|
+
|
|
9
|
+
/** Where the hub learns which sessions exist: a watcher in hub mode, a fixed record otherwise. */
|
|
10
|
+
export interface RecordSource {
|
|
11
|
+
/** Calls back with the full current record set, immediately and on every change. */
|
|
12
|
+
subscribe(listener: (records: SessionRecord[]) => void): void;
|
|
13
|
+
close(): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function pidAlive(pid: number): boolean {
|
|
17
|
+
try {
|
|
18
|
+
process.kill(pid, 0);
|
|
19
|
+
return true;
|
|
20
|
+
} catch (error) {
|
|
21
|
+
// EPERM means the pid exists but belongs to someone else; only ESRCH
|
|
22
|
+
// proves the server is gone.
|
|
23
|
+
return (error as NodeJS.ErrnoException).code !== 'ESRCH';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Watches the registry directory for session records.
|
|
29
|
+
*
|
|
30
|
+
* The 2s poll is the source of truth; fs.watch merely accelerates it, because
|
|
31
|
+
* on macOS watch events around atomic renames are best-effort. Records whose
|
|
32
|
+
* pid is gone are stale by definition (a crashed server cannot withdraw its
|
|
33
|
+
* own record) and are deleted here, the one janitor in the system.
|
|
34
|
+
*/
|
|
35
|
+
export function watchRegistry(registryDir: string, onNotice?: (message: string) => void): RecordSource {
|
|
36
|
+
const recordsDir = sessionRecordsDir(registryDir);
|
|
37
|
+
let listener: ((records: SessionRecord[]) => void) | undefined;
|
|
38
|
+
let watcher: fs.FSWatcher | undefined;
|
|
39
|
+
let debounce: NodeJS.Timeout | undefined;
|
|
40
|
+
let poll: NodeJS.Timeout | undefined;
|
|
41
|
+
let closed = false;
|
|
42
|
+
|
|
43
|
+
const scan = (): SessionRecord[] => {
|
|
44
|
+
let names: string[];
|
|
45
|
+
try {
|
|
46
|
+
names = fs.readdirSync(recordsDir);
|
|
47
|
+
} catch {
|
|
48
|
+
// The directory appears with the first server; an empty registry is not
|
|
49
|
+
// an error.
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
const records: SessionRecord[] = [];
|
|
53
|
+
for (const name of names.filter(isRecordFileName)) {
|
|
54
|
+
let raw: string;
|
|
55
|
+
try {
|
|
56
|
+
raw = fs.readFileSync(path.join(recordsDir, name), 'utf8');
|
|
57
|
+
} catch {
|
|
58
|
+
continue; // Withdrawn between readdir and read; the next scan settles it.
|
|
59
|
+
}
|
|
60
|
+
const record = parseSessionRecord(raw);
|
|
61
|
+
if (!record) continue;
|
|
62
|
+
if (!pidAlive(record.pid)) {
|
|
63
|
+
fs.rmSync(path.join(recordsDir, name), { force: true });
|
|
64
|
+
onNotice?.(`removed stale record for session ${record.id}`);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
records.push(record);
|
|
68
|
+
}
|
|
69
|
+
return records.sort(
|
|
70
|
+
(left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id),
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const emit = (): void => {
|
|
75
|
+
if (!closed) listener?.(scan());
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const ensureWatcher = (): void => {
|
|
79
|
+
if (watcher || closed) return;
|
|
80
|
+
try {
|
|
81
|
+
watcher = fs.watch(recordsDir, () => {
|
|
82
|
+
if (debounce) clearTimeout(debounce);
|
|
83
|
+
debounce = setTimeout(emit, DEBOUNCE_MS);
|
|
84
|
+
});
|
|
85
|
+
watcher.on('error', () => {
|
|
86
|
+
watcher?.close();
|
|
87
|
+
watcher = undefined; // The poll re-establishes it once the dir is back.
|
|
88
|
+
});
|
|
89
|
+
} catch {
|
|
90
|
+
// The directory does not exist yet; the poll keeps trying.
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
subscribe(next) {
|
|
96
|
+
listener = next;
|
|
97
|
+
ensureWatcher();
|
|
98
|
+
emit();
|
|
99
|
+
poll = setInterval(() => {
|
|
100
|
+
ensureWatcher();
|
|
101
|
+
emit();
|
|
102
|
+
}, POLL_MS);
|
|
103
|
+
},
|
|
104
|
+
close() {
|
|
105
|
+
closed = true;
|
|
106
|
+
if (debounce) clearTimeout(debounce);
|
|
107
|
+
if (poll) clearInterval(poll);
|
|
108
|
+
watcher?.close();
|
|
109
|
+
listener = undefined;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|