pim-agent 0.0.1 → 0.9.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 +21 -0
- package/README.md +293 -7
- package/bin/pim.ts +151 -0
- package/package.json +82 -4
- package/packages/core/src/attachments/AttachmentStore.ts +99 -0
- package/packages/core/src/attachments/Attachments.ts +69 -0
- package/packages/core/src/extensions/CoreExtensions.ts +51 -0
- package/packages/core/src/extensions/apply-patch/coordinator.ts +53 -0
- package/packages/core/src/extensions/apply-patch/executor.ts +550 -0
- package/packages/core/src/extensions/apply-patch/index.ts +69 -0
- package/packages/core/src/extensions/apply-patch/matcher.ts +47 -0
- package/packages/core/src/extensions/apply-patch/model.ts +39 -0
- package/packages/core/src/extensions/apply-patch/parser.ts +379 -0
- package/packages/core/src/extensions/apply-patch/render.ts +216 -0
- package/packages/core/src/extensions/apply-patch/schema.ts +37 -0
- package/packages/core/src/extensions/apply-patch/types.ts +30 -0
- package/packages/core/src/extensions/bash/capture.ts +86 -0
- package/packages/core/src/extensions/bash/format.ts +110 -0
- package/packages/core/src/extensions/bash/image.ts +31 -0
- package/packages/core/src/extensions/bash/index.ts +88 -0
- package/packages/core/src/extensions/bash/render.ts +31 -0
- package/packages/core/src/extensions/bash/run.ts +221 -0
- package/packages/core/src/extensions/bash/schema.ts +64 -0
- package/packages/core/src/extensions/edit/edit.ts +366 -0
- package/packages/core/src/extensions/edit/index.ts +36 -0
- package/packages/core/src/extensions/edit/render.ts +16 -0
- package/packages/core/src/extensions/edit/schema.ts +37 -0
- package/packages/core/src/extensions/glob/glob.ts +34 -0
- package/packages/core/src/extensions/glob/index.ts +83 -0
- package/packages/core/src/extensions/glob/render.ts +72 -0
- package/packages/core/src/extensions/glob/schema.ts +66 -0
- package/packages/core/src/extensions/grep/grep.ts +256 -0
- package/packages/core/src/extensions/grep/index.ts +114 -0
- package/packages/core/src/extensions/grep/render.ts +271 -0
- package/packages/core/src/extensions/grep/schema.ts +92 -0
- package/packages/core/src/extensions/read/ImageMemory.ts +42 -0
- package/packages/core/src/extensions/read/image.ts +57 -0
- package/packages/core/src/extensions/read/index.ts +88 -0
- package/packages/core/src/extensions/read/read.ts +191 -0
- package/packages/core/src/extensions/read/render.ts +58 -0
- package/packages/core/src/extensions/read/schema.ts +49 -0
- package/packages/core/src/extensions/subagent/index.ts +33 -0
- package/packages/core/src/extensions/subagent/render.ts +127 -0
- package/packages/core/src/extensions/subagent/schema.ts +9 -0
- package/packages/core/src/extensions/subagent/subagent.ts +419 -0
- package/packages/core/src/extensions/system-prompt/index.ts +28 -0
- package/packages/core/src/extensions/system-prompt/prompt.ts +228 -0
- package/packages/core/src/extensions/todo/index.ts +123 -0
- package/packages/core/src/extensions/todo/render.ts +156 -0
- package/packages/core/src/extensions/todo/schema.ts +24 -0
- package/packages/core/src/extensions/todo/todo.ts +185 -0
- package/packages/core/src/extensions/web-fetch/JinaReaderClient.ts +187 -0
- package/packages/core/src/extensions/web-fetch/WebViewFetchClient.ts +183 -0
- package/packages/core/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +512 -0
- package/packages/core/src/extensions/web-fetch/fetch.ts +265 -0
- package/packages/core/src/extensions/web-fetch/image.ts +165 -0
- package/packages/core/src/extensions/web-fetch/index.ts +83 -0
- package/packages/core/src/extensions/web-fetch/render.ts +100 -0
- package/packages/core/src/extensions/web-fetch/schema.ts +46 -0
- package/packages/core/src/extensions/web-search/ExaMcpClient.ts +251 -0
- package/packages/core/src/extensions/web-search/SearchBreaker.ts +147 -0
- package/packages/core/src/extensions/web-search/SearchChain.ts +77 -0
- package/packages/core/src/extensions/web-search/index.ts +90 -0
- package/packages/core/src/extensions/web-search/providers/DuckDuckGoProvider.ts +195 -0
- package/packages/core/src/extensions/web-search/providers/ExaProvider.ts +54 -0
- package/packages/core/src/extensions/web-search/providers/FirecrawlProvider.ts +144 -0
- package/packages/core/src/extensions/web-search/providers/SearchProvider.ts +118 -0
- package/packages/core/src/extensions/web-search/render.ts +49 -0
- package/packages/core/src/extensions/web-search/schema.ts +29 -0
- package/packages/core/src/extensions/web-search/search.ts +23 -0
- package/packages/core/src/extensions/write/index.ts +58 -0
- package/packages/core/src/extensions/write/render.ts +16 -0
- package/packages/core/src/extensions/write/schema.ts +14 -0
- package/packages/core/src/extensions/write/write.ts +87 -0
- package/packages/core/src/picker/FilePickerSuggestionEngine.ts +14 -0
- package/packages/core/src/picker/InProcessFilePickerSuggestionEngine.ts +52 -0
- package/packages/core/src/picker/PickerItem.ts +6 -0
- package/packages/core/src/picker/PickerService.ts +110 -0
- package/packages/core/src/picker/RemoteFilePickerSuggestionEngine.ts +56 -0
- package/packages/core/src/picker/WorkerFilePickerSuggestionEngine.ts +268 -0
- package/packages/core/src/picker/catalog.ts +220 -0
- package/packages/core/src/picker/commandRanker.ts +17 -0
- package/packages/core/src/picker/filePickerWorker.ts +66 -0
- package/packages/core/src/picker/filePickerWorkerMessages.ts +39 -0
- package/packages/core/src/picker/ranker.ts +245 -0
- package/packages/core/src/picker/token.ts +70 -0
- package/packages/core/src/session/AgentRuntime.ts +85 -0
- package/packages/core/src/session/EventLog.ts +213 -0
- package/packages/core/src/session/MessageText.ts +21 -0
- package/packages/core/src/session/ReadCursors.ts +94 -0
- package/packages/core/src/session/SessionCache.ts +68 -0
- package/packages/core/src/session/SessionHost.ts +876 -0
- package/packages/core/src/session/SessionLease.ts +279 -0
- package/packages/core/src/session/SessionRegistry.ts +169 -0
- package/packages/core/src/session/WriteMark.ts +37 -0
- package/packages/core/src/shared/Cli.ts +25 -0
- package/packages/core/src/shared/DaemonUnit.ts +13 -0
- package/packages/core/src/shared/DiffLines.ts +304 -0
- package/packages/core/src/shared/DiffRenderer.ts +283 -0
- package/packages/core/src/shared/DiffView.ts +29 -0
- package/packages/core/src/shared/Directories.ts +61 -0
- package/packages/core/src/shared/EditMatcher.ts +767 -0
- package/packages/core/src/shared/Errors.ts +9 -0
- package/packages/core/src/shared/ExtensionToggles.ts +130 -0
- package/packages/core/src/shared/FileEnumerator.ts +361 -0
- package/packages/core/src/shared/FileScanner.ts +57 -0
- package/packages/core/src/shared/FileWatch.ts +107 -0
- package/packages/core/src/shared/Format.ts +74 -0
- package/packages/core/src/shared/Fs.ts +91 -0
- package/packages/core/src/shared/FsErrors.ts +67 -0
- package/packages/core/src/shared/FuzzyMatcher.ts +71 -0
- package/packages/core/src/shared/Git.ts +89 -0
- package/packages/core/src/shared/GlobExclusions.ts +13 -0
- package/packages/core/src/shared/Http.ts +14 -0
- package/packages/core/src/shared/ImageMime.ts +36 -0
- package/packages/core/src/shared/Images.ts +357 -0
- package/packages/core/src/shared/Json.ts +19 -0
- package/packages/core/src/shared/Languages.ts +95 -0
- package/packages/core/src/shared/Levenshtein.ts +30 -0
- package/packages/core/src/shared/Lines.ts +79 -0
- package/packages/core/src/shared/McpClient.ts +444 -0
- package/packages/core/src/shared/OutputBudget.ts +84 -0
- package/packages/core/src/shared/PatchSummary.ts +40 -0
- package/packages/core/src/shared/Paths.ts +80 -0
- package/packages/core/src/shared/PimSettings.ts +128 -0
- package/packages/core/src/shared/PimVersion.ts +93 -0
- package/packages/core/src/shared/Pool.ts +26 -0
- package/packages/core/src/shared/Proc.ts +30 -0
- package/packages/core/src/shared/RateLimiter.ts +50 -0
- package/packages/core/src/shared/Renderer.ts +371 -0
- package/packages/core/src/shared/SafePath.ts +17 -0
- package/packages/core/src/shared/SearchRender.ts +82 -0
- package/packages/core/src/shared/SendFile.ts +23 -0
- package/packages/core/src/shared/SpillCache.ts +78 -0
- package/packages/core/src/shared/SubagentLogs.ts +102 -0
- package/packages/core/src/shared/Supervisor.ts +460 -0
- package/packages/core/src/shared/Sweeper.ts +32 -0
- package/packages/core/src/shared/Tools.ts +801 -0
- package/packages/core/src/shared/Updater.ts +193 -0
- package/packages/core/src/view/AnsiPainter.ts +227 -0
- package/packages/core/src/view/BodyRenderer.ts +102 -0
- package/packages/core/src/view/DiffBlocks.ts +52 -0
- package/packages/core/src/view/DiffLayout.ts +69 -0
- package/packages/core/src/view/MarkdownPainter.ts +314 -0
- package/packages/core/src/view/Painting.ts +182 -0
- package/packages/core/src/view/ViewBlock.ts +124 -0
- package/packages/daemon/src/Daemon.ts +63 -0
- package/packages/daemon/src/DaemonInstall.ts +115 -0
- package/packages/daemon/src/Surfaces.ts +66 -0
- package/packages/daemon/src/TelegramSurface.ts +30 -0
- package/packages/daemon/src/WebSurface.ts +57 -0
- package/packages/daemon/src/index.ts +37 -0
- package/packages/protocol/src/Command.ts +92 -0
- package/packages/protocol/src/ImageRoute.ts +23 -0
- package/packages/protocol/src/Protocol.ts +6 -0
- package/packages/protocol/src/ServerEvent.ts +251 -0
- package/packages/server/src/AttachmentEndpoint.ts +184 -0
- package/packages/server/src/ClientConnection.ts +265 -0
- package/packages/server/src/ImageEndpoint.ts +36 -0
- package/packages/server/src/ProbeClient.ts +312 -0
- package/packages/server/src/Reloader.ts +93 -0
- package/packages/server/src/SendFileTool.ts +88 -0
- package/packages/server/src/SessionCatalogue.ts +202 -0
- package/packages/server/src/SessionProjection.ts +194 -0
- package/packages/server/src/SessionStream.ts +601 -0
- package/packages/server/src/StaticClient.ts +95 -0
- package/packages/server/src/WebOptions.ts +72 -0
- package/packages/server/src/WsGateway.ts +563 -0
- package/packages/server/src/probe.ts +146 -0
- package/packages/telegram/src/Bot.ts +229 -0
- package/packages/telegram/src/Commands.ts +768 -0
- package/packages/telegram/src/Config.ts +148 -0
- package/packages/telegram/src/Markdown.ts +235 -0
- package/packages/telegram/src/Message.ts +152 -0
- package/packages/telegram/src/Renderer.ts +456 -0
- package/packages/telegram/src/SendFileSchema.ts +18 -0
- package/packages/telegram/src/SendFileTool.ts +65 -0
- package/packages/telegram/src/Session.ts +252 -0
- package/packages/telegram/src/SessionRegistry.ts +126 -0
- package/packages/telegram/src/TaskScheduler.ts +296 -0
- package/packages/telegram/src/TaskSchema.ts +88 -0
- package/packages/telegram/src/TaskStore.ts +67 -0
- package/packages/telegram/src/TaskTool.ts +208 -0
- package/packages/telegram/src/TelegramHtml.ts +288 -0
- package/packages/telegram/src/TypingIndicator.ts +43 -0
- package/packages/telegram/src/UpdateConfirm.ts +61 -0
- package/packages/tui/src/extensions/_init/index.ts +144 -0
- package/packages/tui/src/extensions/command-picker/index.ts +43 -0
- package/packages/tui/src/extensions/file-picker/index.ts +156 -0
- package/packages/tui/src/extensions/footer/index.ts +117 -0
- package/packages/tui/src/extensions/footer/powerline.ts +87 -0
- package/packages/tui/src/extensions/footer/segments.ts +202 -0
- package/packages/tui/src/extensions/pim/index.ts +89 -0
- package/packages/tui/src/extensions/session-lease/index.ts +248 -0
- package/packages/tui/src/extensions/tps/index.ts +118 -0
- package/packages/tui/src/extensions/working-indicator/index.ts +75 -0
- package/packages/tui/src/extensions/wrapProvider.ts +18 -0
- package/packages/tui/src/themes/pim-dark.json +84 -0
- package/packages/tui/src/themes/pim-light.json +84 -0
- package/packages/tui/src/themes/themeCliArgs.ts +20 -0
- package/packages/web/dist/client/apple-touch-icon.png +0 -0
- package/packages/web/dist/client/assets/bash-LmRjUKlR.js +1 -0
- package/packages/web/dist/client/assets/c-DBVbJFpC.js +1 -0
- package/packages/web/dist/client/assets/clojure-CR7dvLJ4.js +1 -0
- package/packages/web/dist/client/assets/cmake-CgP6gB4W.js +1 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
- package/packages/web/dist/client/assets/core-CGdQx9la.js +3 -0
- package/packages/web/dist/client/assets/cpp-N2d8M7Nc.js +1 -0
- package/packages/web/dist/client/assets/csharp-yYt0Ikks.js +1 -0
- package/packages/web/dist/client/assets/css-D8M8GYaw.js +1 -0
- package/packages/web/dist/client/assets/diff-BhdZizse.js +1 -0
- package/packages/web/dist/client/assets/dockerfile-C0zufGsU.js +1 -0
- package/packages/web/dist/client/assets/elixir-DXIHlGsR.js +1 -0
- package/packages/web/dist/client/assets/erlang-BjYI8dhz.js +1 -0
- package/packages/web/dist/client/assets/go-CYstVVJV.js +1 -0
- package/packages/web/dist/client/assets/graphql-BHHhacaR.js +1 -0
- package/packages/web/dist/client/assets/haskell-DUkhXIda.js +1 -0
- package/packages/web/dist/client/assets/index-BMNwleWO.js +40 -0
- package/packages/web/dist/client/assets/index-CqjB2RPr.css +1 -0
- package/packages/web/dist/client/assets/ini-B1CroMsf.js +1 -0
- package/packages/web/dist/client/assets/java-Dde-Wnb4.js +1 -0
- package/packages/web/dist/client/assets/javascript-8AmHuH5O.js +1 -0
- package/packages/web/dist/client/assets/json-PmyKWHmm.js +1 -0
- package/packages/web/dist/client/assets/kotlin-DgSK8bu-.js +2 -0
- package/packages/web/dist/client/assets/less-Bp0mgmpG.js +1 -0
- package/packages/web/dist/client/assets/lua-1dXYaL-H.js +1 -0
- package/packages/web/dist/client/assets/makefile-BX-o3EIz.js +1 -0
- package/packages/web/dist/client/assets/markdown-D6FrnlqJ.js +1 -0
- package/packages/web/dist/client/assets/ocaml-B-P1Uyq8.js +1 -0
- package/packages/web/dist/client/assets/perl-CvqMZYuf.js +1 -0
- package/packages/web/dist/client/assets/php-DuZl5e-L.js +2 -0
- package/packages/web/dist/client/assets/powershell-BUe16zE4.js +1 -0
- package/packages/web/dist/client/assets/protobuf-DGOW1qT8.js +1 -0
- package/packages/web/dist/client/assets/python-gHmu6VPG.js +1 -0
- package/packages/web/dist/client/assets/r-DNWPCnjT.js +1 -0
- package/packages/web/dist/client/assets/ruby-BGpZ3p4_.js +1 -0
- package/packages/web/dist/client/assets/rust-BIKPVoi6.js +1 -0
- package/packages/web/dist/client/assets/scala-C9lP4FWY.js +1 -0
- package/packages/web/dist/client/assets/scss-gOyOKiXd.js +1 -0
- package/packages/web/dist/client/assets/sql-DWRKyvJ-.js +1 -0
- package/packages/web/dist/client/assets/swift-BTn9__hL.js +1 -0
- package/packages/web/dist/client/assets/typescript-CxkPLmSc.js +1 -0
- package/packages/web/dist/client/assets/vim-Dhhcn_CA.js +1 -0
- package/packages/web/dist/client/assets/xml-COl5yFSC.js +1 -0
- package/packages/web/dist/client/assets/yaml-DjOhhVLG.js +1 -0
- package/packages/web/dist/client/favicon.svg +1 -0
- package/packages/web/dist/client/icon-192.png +0 -0
- package/packages/web/dist/client/icon-512.png +0 -0
- package/packages/web/dist/client/icon-maskable.png +0 -0
- package/packages/web/dist/client/index.html +24 -0
- package/packages/web/dist/client/manifest.webmanifest +21 -0
- package/packages/web/dist/client/wordmark.svg +1 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { PickerService } from "#core/picker/PickerService";
|
|
4
|
+
import { MessageText } from "#core/session/MessageText";
|
|
5
|
+
import type { LeaseState, SessionHost } from "#core/session/SessionHost";
|
|
6
|
+
import { SessionLease, type LeaseRecord } from "#core/session/SessionLease";
|
|
7
|
+
import { FileWatch } from "#core/shared/FileWatch";
|
|
8
|
+
import { Git, type GitState } from "#core/shared/Git";
|
|
9
|
+
import { Tools } from "#core/shared/Tools";
|
|
10
|
+
import type { ToolView } from "#core/view/ViewBlock";
|
|
11
|
+
import type {
|
|
12
|
+
EphemeralEvent,
|
|
13
|
+
ServerEvent,
|
|
14
|
+
StreamEvent,
|
|
15
|
+
} from "#protocol/ServerEvent";
|
|
16
|
+
import { SessionProjection } from "./SessionProjection";
|
|
17
|
+
|
|
18
|
+
export type StreamListener = (event: ServerEvent) => void;
|
|
19
|
+
|
|
20
|
+
type LiveTool = {
|
|
21
|
+
readonly callId: string;
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly args: unknown;
|
|
24
|
+
view: ToolView;
|
|
25
|
+
isError: boolean;
|
|
26
|
+
done: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type LiveMessage = {
|
|
30
|
+
readonly messageId: string;
|
|
31
|
+
text: string;
|
|
32
|
+
thinking: string;
|
|
33
|
+
ended: boolean;
|
|
34
|
+
retired: boolean;
|
|
35
|
+
readonly tools: LiveTool[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const GIT_TTL_MS = 5_000;
|
|
39
|
+
|
|
40
|
+
function sameLease(a: LeaseState, b: LeaseState): boolean {
|
|
41
|
+
return (
|
|
42
|
+
a.writable === b.writable &&
|
|
43
|
+
a.heldBy?.pid === b.heldBy?.pid &&
|
|
44
|
+
a.heldBy?.frontend === b.heldBy?.frontend
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One session's view of the world, shared by every client attached to it and kept running when none are. */
|
|
49
|
+
export class SessionStream {
|
|
50
|
+
public readonly sessionId: string;
|
|
51
|
+
public readonly host: SessionHost;
|
|
52
|
+
public readonly picker: PickerService;
|
|
53
|
+
private readonly sessionPath: string;
|
|
54
|
+
private readonly projection: SessionProjection;
|
|
55
|
+
private readonly listeners = new Set<StreamListener>();
|
|
56
|
+
private readonly pollMs: number | undefined;
|
|
57
|
+
private liveTurn: LiveMessage[] = [];
|
|
58
|
+
private unsubscribe: (() => void) | undefined;
|
|
59
|
+
private unsubscribeLease: (() => void) | undefined;
|
|
60
|
+
private unsubscribeForeign: (() => void) | undefined;
|
|
61
|
+
private unwatch: (() => void) | undefined;
|
|
62
|
+
private holder: LeaseRecord | undefined;
|
|
63
|
+
private lease: LeaseState = { writable: true };
|
|
64
|
+
private draining: Promise<void> = Promise.resolve();
|
|
65
|
+
private drainQueued = false;
|
|
66
|
+
private sentSeq = 0;
|
|
67
|
+
private liveMessageId = 0;
|
|
68
|
+
private turnStartedAt = 0;
|
|
69
|
+
private git: GitState = Git.EMPTY;
|
|
70
|
+
private gitCwd = "";
|
|
71
|
+
private gitReadAt = 0;
|
|
72
|
+
private gitInFlight = false;
|
|
73
|
+
|
|
74
|
+
public constructor(
|
|
75
|
+
sessionId: string,
|
|
76
|
+
host: SessionHost,
|
|
77
|
+
sessionPath: string,
|
|
78
|
+
/** How often the file watch falls back to a poll; the default is a second. */
|
|
79
|
+
pollMs?: number
|
|
80
|
+
) {
|
|
81
|
+
this.sessionId = sessionId;
|
|
82
|
+
this.host = host;
|
|
83
|
+
this.sessionPath = sessionPath;
|
|
84
|
+
this.pollMs = pollMs;
|
|
85
|
+
this.projection = new SessionProjection(sessionPath, () => host.cwd);
|
|
86
|
+
this.picker = new PickerService({
|
|
87
|
+
cwd: () => host.cwd,
|
|
88
|
+
agentDir: host.agentDir,
|
|
89
|
+
agent: () => host.agentSession,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Follow the host, not one agent: a rehydrated session is a new `AgentSession`. */
|
|
94
|
+
public start(): void {
|
|
95
|
+
this.unsubscribe ??= this.host.subscribe((event) => {
|
|
96
|
+
this.onAgentEvent(event);
|
|
97
|
+
});
|
|
98
|
+
this.unsubscribeLease ??= this.host.onLeaseChange(() => {
|
|
99
|
+
void this.pushLease();
|
|
100
|
+
});
|
|
101
|
+
this.unsubscribeForeign ??= this.host.onForeignWrite(() => {
|
|
102
|
+
this.push({
|
|
103
|
+
type: "error",
|
|
104
|
+
message:
|
|
105
|
+
"Another process is writing this session; its history may be inconsistent.",
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Watch the lease and the session file while a client is reading: un-greying
|
|
112
|
+
* has to feel instant, and a turn another process runs reaches the browser
|
|
113
|
+
* only through the file, which emits no agent event here. Idle sessions pay
|
|
114
|
+
* nothing.
|
|
115
|
+
*/
|
|
116
|
+
public watchFiles(active: boolean): void {
|
|
117
|
+
if (active === (this.unwatch !== undefined)) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!active) {
|
|
121
|
+
this.unwatch?.();
|
|
122
|
+
this.unwatch = undefined;
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const stops = [
|
|
126
|
+
SessionLease.watch(this.sessionPath, () => {
|
|
127
|
+
void this.pushLease();
|
|
128
|
+
}),
|
|
129
|
+
FileWatch.file(
|
|
130
|
+
this.sessionPath,
|
|
131
|
+
() => {
|
|
132
|
+
this.scheduleDrain();
|
|
133
|
+
},
|
|
134
|
+
this.pollMs
|
|
135
|
+
),
|
|
136
|
+
];
|
|
137
|
+
this.unwatch = (): void => {
|
|
138
|
+
for (const stop of stops) {
|
|
139
|
+
stop();
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Project whatever pi has appended since the last read, telling every client; returns the head. */
|
|
145
|
+
public async refresh(): Promise<number> {
|
|
146
|
+
await this.flushDurable();
|
|
147
|
+
return this.projection.head;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
public subscribe(listener: StreamListener): () => void {
|
|
151
|
+
this.listeners.add(listener);
|
|
152
|
+
return () => {
|
|
153
|
+
this.listeners.delete(listener);
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Everything a client at `fromSeq` has not seen: the durable tail, the in-flight turn coalesced, then state. */
|
|
158
|
+
public async replay(fromSeq: number): Promise<readonly StreamEvent[]> {
|
|
159
|
+
await Promise.all([this.flushDurable(), this.readLease()]);
|
|
160
|
+
return [...this.projection.since(fromSeq), ...this.inFlight()];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private inFlight(): readonly StreamEvent[] {
|
|
164
|
+
const events: StreamEvent[] = [];
|
|
165
|
+
for (const message of this.liveTurn) {
|
|
166
|
+
const { messageId } = message;
|
|
167
|
+
events.push({ type: "message_start", role: "assistant", messageId });
|
|
168
|
+
if (message.thinking) {
|
|
169
|
+
events.push({
|
|
170
|
+
type: "thinking_delta",
|
|
171
|
+
messageId,
|
|
172
|
+
delta: message.thinking,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
if (message.text) {
|
|
176
|
+
events.push({ type: "text_delta", messageId, delta: message.text });
|
|
177
|
+
}
|
|
178
|
+
for (const tool of message.tools) {
|
|
179
|
+
events.push({
|
|
180
|
+
type: "tool_call",
|
|
181
|
+
callId: tool.callId,
|
|
182
|
+
name: tool.name,
|
|
183
|
+
messageId,
|
|
184
|
+
view: tool.view,
|
|
185
|
+
});
|
|
186
|
+
if (tool.done) {
|
|
187
|
+
events.push({
|
|
188
|
+
type: "tool_end",
|
|
189
|
+
callId: tool.callId,
|
|
190
|
+
view: tool.view,
|
|
191
|
+
isError: tool.isError,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
events.push(this.sessionState());
|
|
197
|
+
return events;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Broadcast an event the stream did not derive itself, e.g. a state push. */
|
|
201
|
+
public push(event: ServerEvent): void {
|
|
202
|
+
this.emit(event);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Drop the server's picker caches and tell every client to drop theirs. */
|
|
206
|
+
public invalidatePickers(scope: "files" | "commands" | "all"): void {
|
|
207
|
+
this.picker.invalidate();
|
|
208
|
+
this.emit({ type: "picker_invalidate", scope, cwd: this.host.cwd });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
public sessionState(): EphemeralEvent {
|
|
212
|
+
const tps = this.host.tps;
|
|
213
|
+
const usage = this.host.usage();
|
|
214
|
+
const { branch, dirtyCount, ahead, behind } = this.gitState();
|
|
215
|
+
const modelLabel = this.host.currentModelLabel;
|
|
216
|
+
const turnElapsedMs =
|
|
217
|
+
this.host.status === "idle" || this.turnStartedAt === 0
|
|
218
|
+
? undefined
|
|
219
|
+
: Date.now() - this.turnStartedAt;
|
|
220
|
+
return {
|
|
221
|
+
type: "session_state",
|
|
222
|
+
cwd: this.host.cwd,
|
|
223
|
+
model: this.host.currentModelId ?? "",
|
|
224
|
+
...(modelLabel === undefined ? {} : { modelLabel }),
|
|
225
|
+
thinking: this.host.currentThinkingLevel,
|
|
226
|
+
cost: this.host.settings.cumulativeCost ?? 0,
|
|
227
|
+
status: this.host.status,
|
|
228
|
+
...this.leaseState(),
|
|
229
|
+
...(tps === undefined ? {} : { tps }),
|
|
230
|
+
...(turnElapsedMs === undefined ? {} : { turnElapsedMs }),
|
|
231
|
+
...(usage?.percent === null || usage === undefined
|
|
232
|
+
? {}
|
|
233
|
+
: {
|
|
234
|
+
contextPercent: usage.percent,
|
|
235
|
+
contextWindow: usage.contextWindow,
|
|
236
|
+
}),
|
|
237
|
+
...(branch === null ? {} : { branch, dirtyCount, ahead, behind }),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Two sources, and both are needed: the host knows only about a mutation of
|
|
243
|
+
* its own parked behind someone else, and reads writable for a session
|
|
244
|
+
* merely sitting idle under a foreign lease — which the cached record covers.
|
|
245
|
+
*/
|
|
246
|
+
private leaseState(): LeaseState {
|
|
247
|
+
const own = this.host.leaseState;
|
|
248
|
+
if (!own.writable) {
|
|
249
|
+
return own;
|
|
250
|
+
}
|
|
251
|
+
const holder = this.holder;
|
|
252
|
+
// Our own turn: the lease exists because this process took it.
|
|
253
|
+
if (
|
|
254
|
+
holder === undefined ||
|
|
255
|
+
(holder.pid === process.pid && holder.frontend === "daemon")
|
|
256
|
+
) {
|
|
257
|
+
return { writable: true };
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
writable: false,
|
|
261
|
+
heldBy: { frontend: holder.frontend, pid: holder.pid },
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Re-reads the holder; true when what a client would render changed. Our own
|
|
267
|
+
* lease comes and goes on every turn we take and says nothing, so only a
|
|
268
|
+
* holder that is not us ever moves this.
|
|
269
|
+
*/
|
|
270
|
+
private async readLease(): Promise<boolean> {
|
|
271
|
+
this.holder = await SessionLease.read(this.sessionPath);
|
|
272
|
+
const next = this.leaseState();
|
|
273
|
+
if (sameLease(this.lease, next)) {
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
this.lease = next;
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private async pushLease(): Promise<void> {
|
|
281
|
+
if (await this.readLease()) {
|
|
282
|
+
this.emit(this.sessionState());
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private gitState(): GitState {
|
|
287
|
+
const cwd = this.host.cwd;
|
|
288
|
+
const moved = cwd !== this.gitCwd;
|
|
289
|
+
if (moved) {
|
|
290
|
+
this.gitCwd = cwd;
|
|
291
|
+
this.git = Git.EMPTY;
|
|
292
|
+
}
|
|
293
|
+
if (
|
|
294
|
+
(moved || Date.now() - this.gitReadAt > GIT_TTL_MS) &&
|
|
295
|
+
!this.gitInFlight
|
|
296
|
+
) {
|
|
297
|
+
this.gitInFlight = true;
|
|
298
|
+
void Git.fetchStatus(cwd)
|
|
299
|
+
.then((next) => {
|
|
300
|
+
this.gitInFlight = false;
|
|
301
|
+
this.gitReadAt = Date.now();
|
|
302
|
+
const changed =
|
|
303
|
+
next.branch !== this.git.branch ||
|
|
304
|
+
next.dirtyCount !== this.git.dirtyCount ||
|
|
305
|
+
next.ahead !== this.git.ahead ||
|
|
306
|
+
next.behind !== this.git.behind;
|
|
307
|
+
this.git = next;
|
|
308
|
+
if (changed) {
|
|
309
|
+
this.emit(this.sessionState());
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
.catch(() => {});
|
|
313
|
+
}
|
|
314
|
+
return this.git;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
public dispose(): void {
|
|
318
|
+
this.unsubscribe?.();
|
|
319
|
+
this.unsubscribe = undefined;
|
|
320
|
+
this.unsubscribeLease?.();
|
|
321
|
+
this.unsubscribeLease = undefined;
|
|
322
|
+
this.unsubscribeForeign?.();
|
|
323
|
+
this.unsubscribeForeign = undefined;
|
|
324
|
+
this.watchFiles(false);
|
|
325
|
+
this.listeners.clear();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private onAgentEvent(event: AgentSessionEvent): void {
|
|
329
|
+
switch (event.type) {
|
|
330
|
+
case "agent_start":
|
|
331
|
+
this.turnStartedAt = Date.now();
|
|
332
|
+
this.liveTurn = [];
|
|
333
|
+
this.emit(this.sessionState());
|
|
334
|
+
return;
|
|
335
|
+
case "message_start":
|
|
336
|
+
if (event.message.role === "assistant") {
|
|
337
|
+
this.startMessage();
|
|
338
|
+
this.emit(this.sessionState());
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
|
+
case "message_update": {
|
|
342
|
+
if (event.message.role !== "assistant") {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const message = this.currentMessage();
|
|
346
|
+
const text = MessageText.textOf(event.message.content);
|
|
347
|
+
const thinking = MessageText.textOf(event.message.content, "thinking");
|
|
348
|
+
this.emitDelta(message, "thinking", thinking);
|
|
349
|
+
this.emitDelta(message, "text", text);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
case "tool_execution_start": {
|
|
353
|
+
const view = Tools.viewOf({
|
|
354
|
+
name: event.toolName,
|
|
355
|
+
args: event.args,
|
|
356
|
+
isPartial: true,
|
|
357
|
+
cwd: this.host.cwd,
|
|
358
|
+
});
|
|
359
|
+
const message = this.currentMessage();
|
|
360
|
+
message.tools.push({
|
|
361
|
+
callId: event.toolCallId,
|
|
362
|
+
name: event.toolName,
|
|
363
|
+
args: event.args,
|
|
364
|
+
view,
|
|
365
|
+
isError: false,
|
|
366
|
+
done: false,
|
|
367
|
+
});
|
|
368
|
+
this.emit({
|
|
369
|
+
type: "tool_call",
|
|
370
|
+
callId: event.toolCallId,
|
|
371
|
+
name: event.toolName,
|
|
372
|
+
messageId: message.messageId,
|
|
373
|
+
view,
|
|
374
|
+
});
|
|
375
|
+
this.emit(this.sessionState());
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
case "tool_execution_update": {
|
|
379
|
+
const view = Tools.viewOf({
|
|
380
|
+
name: event.toolName,
|
|
381
|
+
args: event.args,
|
|
382
|
+
result: event.partialResult,
|
|
383
|
+
isPartial: true,
|
|
384
|
+
cwd: this.host.cwd,
|
|
385
|
+
});
|
|
386
|
+
const tool = this.findTool(event.toolCallId);
|
|
387
|
+
if (tool) {
|
|
388
|
+
tool.view = view;
|
|
389
|
+
}
|
|
390
|
+
this.emit({ type: "tool_update", callId: event.toolCallId, view });
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
case "tool_execution_end": {
|
|
394
|
+
const tool = this.findTool(event.toolCallId);
|
|
395
|
+
const view = Tools.viewOf({
|
|
396
|
+
name: event.toolName,
|
|
397
|
+
args: tool?.args,
|
|
398
|
+
result: event.result,
|
|
399
|
+
isError: event.isError,
|
|
400
|
+
isPartial: false,
|
|
401
|
+
cwd: this.host.cwd,
|
|
402
|
+
});
|
|
403
|
+
if (tool) {
|
|
404
|
+
tool.view = view;
|
|
405
|
+
tool.isError = event.isError;
|
|
406
|
+
tool.done = true;
|
|
407
|
+
}
|
|
408
|
+
this.emit({
|
|
409
|
+
type: "tool_end",
|
|
410
|
+
callId: event.toolCallId,
|
|
411
|
+
view,
|
|
412
|
+
isError: event.isError,
|
|
413
|
+
});
|
|
414
|
+
if (Tools.effectOf(event.toolName)?.kind !== "readOnly") {
|
|
415
|
+
this.invalidatePickers("files");
|
|
416
|
+
}
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
case "entry_appended":
|
|
420
|
+
void this.flushDurable();
|
|
421
|
+
return;
|
|
422
|
+
// Never await the flush: pi appends the entry only after this listener returns.
|
|
423
|
+
case "message_end": {
|
|
424
|
+
if (event.message.role === "assistant") {
|
|
425
|
+
const open = this.liveTurn.at(-1);
|
|
426
|
+
if (open) {
|
|
427
|
+
open.ended = true;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
void this.flushDurable();
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
case "turn_end": {
|
|
434
|
+
const usage =
|
|
435
|
+
event.message.role === "assistant" ? event.message.usage : undefined;
|
|
436
|
+
this.emit({
|
|
437
|
+
type: "turn_end",
|
|
438
|
+
stats: {
|
|
439
|
+
inputTokens: usage?.input ?? 0,
|
|
440
|
+
outputTokens: usage?.output ?? 0,
|
|
441
|
+
costUsd: usage?.cost.total ?? 0,
|
|
442
|
+
durationMs: Date.now() - this.turnStartedAt,
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
case "agent_settled":
|
|
448
|
+
void this.flushDurable().then(() => {
|
|
449
|
+
// Clear only once the durable events superseding it are on the wire.
|
|
450
|
+
this.liveTurn = [];
|
|
451
|
+
this.emit(this.sessionState());
|
|
452
|
+
});
|
|
453
|
+
return;
|
|
454
|
+
default:
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private currentMessage(): LiveMessage {
|
|
460
|
+
return this.liveTurn.at(-1) ?? this.startMessage();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
private startMessage(): LiveMessage {
|
|
464
|
+
this.liveMessageId += 1;
|
|
465
|
+
const message: LiveMessage = {
|
|
466
|
+
messageId: `${this.sessionId}:live:${this.liveMessageId}`,
|
|
467
|
+
text: "",
|
|
468
|
+
thinking: "",
|
|
469
|
+
ended: false,
|
|
470
|
+
retired: false,
|
|
471
|
+
tools: [],
|
|
472
|
+
};
|
|
473
|
+
this.liveTurn.push(message);
|
|
474
|
+
this.emit({
|
|
475
|
+
type: "message_start",
|
|
476
|
+
role: "assistant",
|
|
477
|
+
messageId: message.messageId,
|
|
478
|
+
});
|
|
479
|
+
return message;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Pi re-states the whole message on each update; only a suffix is expressible as a delta.
|
|
483
|
+
private emitDelta(
|
|
484
|
+
message: LiveMessage,
|
|
485
|
+
channel: "text" | "thinking",
|
|
486
|
+
next: string
|
|
487
|
+
): void {
|
|
488
|
+
const sent = message[channel];
|
|
489
|
+
if (!next.startsWith(sent) || next === sent) {
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
this.emit({
|
|
493
|
+
type: channel === "text" ? "text_delta" : "thinking_delta",
|
|
494
|
+
messageId: message.messageId,
|
|
495
|
+
delta: next.slice(sent.length),
|
|
496
|
+
});
|
|
497
|
+
message[channel] = next;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private locate(
|
|
501
|
+
callId: string
|
|
502
|
+
): { readonly message: LiveMessage; readonly at: number } | undefined {
|
|
503
|
+
for (const message of this.liveTurn) {
|
|
504
|
+
const at = message.tools.findIndex((tool) => tool.callId === callId);
|
|
505
|
+
if (at !== -1) {
|
|
506
|
+
return { message, at };
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
return undefined;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private findTool(callId: string): LiveTool | undefined {
|
|
513
|
+
const found = this.locate(callId);
|
|
514
|
+
return found === undefined ? undefined : found.message.tools[found.at];
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Serialised: two reads in flight would race on `sentSeq` and hand a client
|
|
519
|
+
* the same lines twice, or out of order. The agent-event path and the file
|
|
520
|
+
* watch both come through here, so they can only ever queue behind one
|
|
521
|
+
* another.
|
|
522
|
+
*/
|
|
523
|
+
private flushDurable(): Promise<void> {
|
|
524
|
+
const done = this.draining.then(() => this.drainDurable());
|
|
525
|
+
this.draining = done.catch(() => {});
|
|
526
|
+
return done;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// A burst of appends is one drain: a trigger waits for the read already running, and one arriving after that starts gets its own.
|
|
530
|
+
private scheduleDrain(): void {
|
|
531
|
+
if (this.drainQueued) {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
this.drainQueued = true;
|
|
535
|
+
void this.draining
|
|
536
|
+
.then(() => {
|
|
537
|
+
this.drainQueued = false;
|
|
538
|
+
return this.flushDurable();
|
|
539
|
+
})
|
|
540
|
+
.catch(() => {});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// One frame: a retire split from the durable message that caused it paints the step twice.
|
|
544
|
+
private async drainDurable(): Promise<void> {
|
|
545
|
+
await this.projection.drain();
|
|
546
|
+
// The watermark is what every listener has been offered, so one client's read cannot swallow another's tail.
|
|
547
|
+
const fresh = this.projection.since(this.sentSeq);
|
|
548
|
+
this.sentSeq = this.projection.head;
|
|
549
|
+
const batch: StreamEvent[] = [];
|
|
550
|
+
for (const event of fresh) {
|
|
551
|
+
batch.push(event);
|
|
552
|
+
if (event.type === "message" && event.role === "assistant") {
|
|
553
|
+
const retired = this.retireLive();
|
|
554
|
+
if (retired) {
|
|
555
|
+
batch.push(retired);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (event.type === "tool_result") {
|
|
559
|
+
this.settleLive(event.callId);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (batch.length > 0) {
|
|
563
|
+
this.emit({ type: "replay", events: batch });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Retire the oldest *finished* message, never the oldest: a retired shell may still be collecting calls.
|
|
568
|
+
private retireLive(): EphemeralEvent | undefined {
|
|
569
|
+
const retired = this.liveTurn.find(
|
|
570
|
+
(message) => message.ended && !message.retired
|
|
571
|
+
);
|
|
572
|
+
if (!retired) {
|
|
573
|
+
return undefined;
|
|
574
|
+
}
|
|
575
|
+
retired.text = "";
|
|
576
|
+
retired.thinking = "";
|
|
577
|
+
retired.retired = true;
|
|
578
|
+
if (retired.tools.length === 0) {
|
|
579
|
+
this.liveTurn = this.liveTurn.filter((message) => message !== retired);
|
|
580
|
+
}
|
|
581
|
+
return { type: "message_retire", messageId: retired.messageId };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
private settleLive(callId: string): void {
|
|
585
|
+
const found = this.locate(callId);
|
|
586
|
+
if (found === undefined) {
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const { message } = found;
|
|
590
|
+
message.tools.splice(found.at, 1);
|
|
591
|
+
if (message.retired && message.tools.length === 0) {
|
|
592
|
+
this.liveTurn = this.liveTurn.filter((held) => held !== message);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private emit(event: ServerEvent): void {
|
|
597
|
+
for (const listener of this.listeners) {
|
|
598
|
+
listener(event);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { join, resolve, sep } from "node:path";
|
|
2
|
+
|
|
3
|
+
/** The built Vite bundle, resolved relative to this file so it works installed. */
|
|
4
|
+
export const DEFAULT_CLIENT_DIR = resolve(
|
|
5
|
+
import.meta.dir,
|
|
6
|
+
"..",
|
|
7
|
+
"..",
|
|
8
|
+
"web",
|
|
9
|
+
"dist",
|
|
10
|
+
"client"
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const BUILD_HINT =
|
|
14
|
+
"The pim web client has not been built.\n\n" +
|
|
15
|
+
"Run `bun run web:build` in the pim checkout, then restart `pim --mode web`.\n" +
|
|
16
|
+
"(Installed copies ship the bundle; a git checkout has to build it once.)\n";
|
|
17
|
+
|
|
18
|
+
/** Vite fingerprints everything under `assets/`, so it can never go stale. */
|
|
19
|
+
export const IMMUTABLE = "public, max-age=31536000, immutable";
|
|
20
|
+
|
|
21
|
+
/** A file off disk under a caching policy, or the 404 that a path naming nothing is. */
|
|
22
|
+
export async function serveFile(
|
|
23
|
+
path: string,
|
|
24
|
+
cacheControl: string
|
|
25
|
+
): Promise<Response> {
|
|
26
|
+
const file = Bun.file(path);
|
|
27
|
+
return (await file.exists())
|
|
28
|
+
? new Response(file, { headers: { "cache-control": cacheControl } })
|
|
29
|
+
: new Response("not found", { status: 404 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function looksLikeAsset(pathname: string): boolean {
|
|
33
|
+
const last = pathname.slice(pathname.lastIndexOf("/") + 1);
|
|
34
|
+
return /\.[a-zA-Z0-9]+$/.test(last);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Serves the built browser client as a static SPA; unknown routes fall back to `index.html`. */
|
|
38
|
+
export class StaticClient {
|
|
39
|
+
private readonly root: string;
|
|
40
|
+
|
|
41
|
+
public constructor(root: string = DEFAULT_CLIENT_DIR) {
|
|
42
|
+
this.root = resolve(root);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public async handle(request: Request): Promise<Response> {
|
|
46
|
+
const { pathname } = new URL(request.url);
|
|
47
|
+
const path = this.within(pathname);
|
|
48
|
+
if (path !== undefined) {
|
|
49
|
+
const file = Bun.file(path);
|
|
50
|
+
if (await file.exists()) {
|
|
51
|
+
return new Response(file, {
|
|
52
|
+
headers: {
|
|
53
|
+
"cache-control": pathname.startsWith("/assets/")
|
|
54
|
+
? IMMUTABLE
|
|
55
|
+
: "no-cache",
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (looksLikeAsset(pathname)) {
|
|
61
|
+
return new Response("not found", { status: 404 });
|
|
62
|
+
}
|
|
63
|
+
const index = Bun.file(join(this.root, "index.html"));
|
|
64
|
+
if (!(await index.exists())) {
|
|
65
|
+
return new Response(BUILD_HINT, {
|
|
66
|
+
status: 503,
|
|
67
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return new Response(index, {
|
|
71
|
+
headers: {
|
|
72
|
+
"content-type": "text/html; charset=utf-8",
|
|
73
|
+
"cache-control": "no-cache",
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// `new URL()` normalises literal `../` but not its percent-encoded spelling: re-resolve the decoded path.
|
|
79
|
+
private within(pathname: string): string | undefined {
|
|
80
|
+
let decoded: string;
|
|
81
|
+
try {
|
|
82
|
+
decoded = decodeURIComponent(pathname);
|
|
83
|
+
} catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
if (decoded.includes("\0")) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
const candidate = resolve(join(this.root, decoded));
|
|
90
|
+
if (candidate !== this.root && !candidate.startsWith(this.root + sep)) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
return candidate === this.root ? undefined : candidate;
|
|
94
|
+
}
|
|
95
|
+
}
|