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,312 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
import type { PickerItem } from "#core/picker/PickerItem";
|
|
4
|
+
import { RemoteFilePickerSuggestionEngine } from "#core/picker/RemoteFilePickerSuggestionEngine";
|
|
5
|
+
import type { AttachmentRef, CommandDraft } from "#protocol/Command";
|
|
6
|
+
import { PROTOCOL_VERSION } from "#protocol/Protocol";
|
|
7
|
+
import {
|
|
8
|
+
isDurableEvent,
|
|
9
|
+
type ModelView,
|
|
10
|
+
type ResponseEvent,
|
|
11
|
+
type ServerEvent,
|
|
12
|
+
type SessionSummaryView,
|
|
13
|
+
} from "#protocol/ServerEvent";
|
|
14
|
+
|
|
15
|
+
export type ProbeOptions = {
|
|
16
|
+
readonly url: string;
|
|
17
|
+
/** Omit to have the server create a session; the id comes back on attach. */
|
|
18
|
+
readonly sessionId?: string;
|
|
19
|
+
readonly cwd?: string;
|
|
20
|
+
readonly fromSeq?: number;
|
|
21
|
+
/** Called for every frame, in arrival order. */
|
|
22
|
+
readonly onEvent?: (event: ServerEvent) => void;
|
|
23
|
+
/** Sent instead of the real one, to exercise version rejection. */
|
|
24
|
+
readonly protocolVersion?: number;
|
|
25
|
+
/** Keystroke debounce for `files`; 0 makes tests deterministic. */
|
|
26
|
+
readonly debounceMs?: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** What `POST /upload` answers with, all of it server-side. */
|
|
30
|
+
export type UploadedFile = {
|
|
31
|
+
readonly id: string;
|
|
32
|
+
readonly path: string;
|
|
33
|
+
/** Where the bytes can be read back, relative to the server's origin. */
|
|
34
|
+
readonly url: string;
|
|
35
|
+
readonly mimeType: string;
|
|
36
|
+
readonly isImage: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type Pending = {
|
|
40
|
+
readonly resolve: (response: ResponseEvent) => void;
|
|
41
|
+
readonly reject: (err: Error) => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** The reference client: speaks the whole protocol, renders nothing. */
|
|
45
|
+
export class ProbeClient {
|
|
46
|
+
public sessionId: string | undefined;
|
|
47
|
+
public seq: number;
|
|
48
|
+
public readonly events: ServerEvent[] = [];
|
|
49
|
+
/** The `@` picker, answered by the server one query at a time. */
|
|
50
|
+
public readonly files: RemoteFilePickerSuggestionEngine;
|
|
51
|
+
private readonly options: ProbeOptions;
|
|
52
|
+
private readonly pending = new Map<string, Pending>();
|
|
53
|
+
private readonly waiters = new Set<{
|
|
54
|
+
readonly test: (event: ServerEvent) => boolean;
|
|
55
|
+
readonly resolve: (event: ServerEvent) => void;
|
|
56
|
+
}>();
|
|
57
|
+
private socket: WebSocket | undefined;
|
|
58
|
+
private nextId = 0;
|
|
59
|
+
private closeInfo:
|
|
60
|
+
| { readonly code: number; readonly reason: string }
|
|
61
|
+
| undefined;
|
|
62
|
+
|
|
63
|
+
public constructor(options: ProbeOptions) {
|
|
64
|
+
this.options = options;
|
|
65
|
+
this.sessionId = options.sessionId;
|
|
66
|
+
this.seq = options.fromSeq ?? 0;
|
|
67
|
+
this.files = new RemoteFilePickerSuggestionEngine(
|
|
68
|
+
(query, limit) => this.pickFiles(query, limit),
|
|
69
|
+
options.debounceMs
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Connects and completes the resume handshake; resolves once replay ends. */
|
|
74
|
+
public async connect(): Promise<ResponseEvent> {
|
|
75
|
+
const socket = new WebSocket(this.options.url);
|
|
76
|
+
this.socket = socket;
|
|
77
|
+
socket.addEventListener("message", (event) => {
|
|
78
|
+
this.receive(String(event.data));
|
|
79
|
+
});
|
|
80
|
+
socket.addEventListener("close", (event) => {
|
|
81
|
+
this.closeInfo = { code: event.code, reason: event.reason };
|
|
82
|
+
for (const { reject } of this.pending.values()) {
|
|
83
|
+
reject(new Error(`socket closed: ${event.code} ${event.reason}`));
|
|
84
|
+
}
|
|
85
|
+
this.pending.clear();
|
|
86
|
+
});
|
|
87
|
+
await new Promise<void>((resolve, reject) => {
|
|
88
|
+
socket.addEventListener("open", () => {
|
|
89
|
+
resolve();
|
|
90
|
+
});
|
|
91
|
+
socket.addEventListener("error", () => {
|
|
92
|
+
reject(new Error(`could not connect to ${this.options.url}`));
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
return await this.send({
|
|
96
|
+
type: "attach",
|
|
97
|
+
protocolVersion: (this.options.protocolVersion ??
|
|
98
|
+
PROTOCOL_VERSION) as typeof PROTOCOL_VERSION,
|
|
99
|
+
...(this.sessionId === undefined ? {} : { sessionId: this.sessionId }),
|
|
100
|
+
...(this.options.cwd === undefined ? {} : { cwd: this.options.cwd }),
|
|
101
|
+
fromSeq: this.seq,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
public send(command: CommandDraft): Promise<ResponseEvent> {
|
|
106
|
+
const socket = this.socket;
|
|
107
|
+
if (!socket) {
|
|
108
|
+
return Promise.reject(new Error("probe is not connected"));
|
|
109
|
+
}
|
|
110
|
+
const id = `probe-${++this.nextId}`;
|
|
111
|
+
const frame = { ...command, id };
|
|
112
|
+
return new Promise<ResponseEvent>((resolve, reject) => {
|
|
113
|
+
this.pending.set(id, { resolve, reject });
|
|
114
|
+
socket.send(JSON.stringify(frame));
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public prompt(text: string): Promise<ResponseEvent> {
|
|
119
|
+
return this.send({
|
|
120
|
+
type: "user_message",
|
|
121
|
+
sessionId: this.sessionId ?? "",
|
|
122
|
+
text,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
public async pickFiles(
|
|
127
|
+
query: string,
|
|
128
|
+
limit = 50
|
|
129
|
+
): Promise<readonly PickerItem[]> {
|
|
130
|
+
const response = await this.send({
|
|
131
|
+
type: "pick_files",
|
|
132
|
+
sessionId: this.sessionId ?? "",
|
|
133
|
+
query,
|
|
134
|
+
limit,
|
|
135
|
+
});
|
|
136
|
+
return itemsOf(response);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
public async pickCommands(
|
|
140
|
+
query: string,
|
|
141
|
+
limit?: number
|
|
142
|
+
): Promise<readonly PickerItem[]> {
|
|
143
|
+
const response = await this.send({
|
|
144
|
+
type: "pick_commands",
|
|
145
|
+
sessionId: this.sessionId ?? "",
|
|
146
|
+
query,
|
|
147
|
+
...(limit === undefined ? {} : { limit }),
|
|
148
|
+
});
|
|
149
|
+
return itemsOf(response);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Reads one subagent's transcript; its events stay enveloped in `events`. */
|
|
153
|
+
public watchSubagent(callId: string, fromSeq = 0): Promise<ResponseEvent> {
|
|
154
|
+
return this.send({
|
|
155
|
+
type: "watch_subagent",
|
|
156
|
+
sessionId: this.sessionId ?? "",
|
|
157
|
+
callId,
|
|
158
|
+
fromSeq,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
public unwatchSubagent(callId: string): Promise<ResponseEvent> {
|
|
163
|
+
return this.send({ type: "unwatch_subagent", callId });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Pi's session catalogue; answers whether or not this probe is attached. */
|
|
167
|
+
public async listSessions(
|
|
168
|
+
cwd?: string
|
|
169
|
+
): Promise<readonly SessionSummaryView[]> {
|
|
170
|
+
const response = await this.send({
|
|
171
|
+
type: "list_sessions",
|
|
172
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
173
|
+
});
|
|
174
|
+
if (!response.success) {
|
|
175
|
+
throw new Error(response.error ?? "list_sessions failed");
|
|
176
|
+
}
|
|
177
|
+
return response.sessions ?? [];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The model catalogue, plus this session's thinking levels; empty when unattached. */
|
|
181
|
+
public async listModels(): Promise<{
|
|
182
|
+
readonly models: readonly ModelView[];
|
|
183
|
+
readonly thinkingLevels: readonly string[];
|
|
184
|
+
}> {
|
|
185
|
+
const response = await this.send({ type: "list_models" });
|
|
186
|
+
if (!response.success) {
|
|
187
|
+
throw new Error(response.error ?? "list_models failed");
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
models: response.models ?? [],
|
|
191
|
+
thinkingLevels: response.thinkingLevels ?? [],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Transfers a client-local file into the server's world; only the bytes and bare filename are sent. */
|
|
196
|
+
public async upload(localPath: string): Promise<UploadedFile> {
|
|
197
|
+
const file = Bun.file(localPath);
|
|
198
|
+
const form = new FormData();
|
|
199
|
+
form.append("file", file, basename(localPath));
|
|
200
|
+
const response = await fetch(
|
|
201
|
+
`${this.httpUrl}/upload?session=${encodeURIComponent(this.sessionId ?? "")}`,
|
|
202
|
+
{ method: "POST", body: form }
|
|
203
|
+
);
|
|
204
|
+
const body = (await response.json()) as UploadedFile & {
|
|
205
|
+
readonly error?: string;
|
|
206
|
+
};
|
|
207
|
+
if (!response.ok) {
|
|
208
|
+
throw new Error(`upload failed: ${body.error ?? response.status}`);
|
|
209
|
+
}
|
|
210
|
+
return body;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
public promptWith(
|
|
214
|
+
text: string,
|
|
215
|
+
attachments: readonly AttachmentRef[]
|
|
216
|
+
): Promise<ResponseEvent> {
|
|
217
|
+
return this.send({
|
|
218
|
+
type: "user_message",
|
|
219
|
+
sessionId: this.sessionId ?? "",
|
|
220
|
+
text,
|
|
221
|
+
attachments,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
public get httpUrl(): string {
|
|
226
|
+
return this.options.url.replace(/^ws/, "http");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** `from` skips frames already received, so a repeated state can be awaited. */
|
|
230
|
+
public waitFor(
|
|
231
|
+
test: (event: ServerEvent) => boolean,
|
|
232
|
+
opts?: { readonly timeoutMs?: number; readonly from?: number }
|
|
233
|
+
): Promise<ServerEvent> {
|
|
234
|
+
const timeoutMs = opts?.timeoutMs ?? 30_000;
|
|
235
|
+
const hit = this.events.slice(opts?.from ?? 0).find(test);
|
|
236
|
+
if (hit) {
|
|
237
|
+
return Promise.resolve(hit);
|
|
238
|
+
}
|
|
239
|
+
return new Promise<ServerEvent>((resolve, reject) => {
|
|
240
|
+
const waiter = { test, resolve };
|
|
241
|
+
this.waiters.add(waiter);
|
|
242
|
+
const timer = setTimeout(() => {
|
|
243
|
+
this.waiters.delete(waiter);
|
|
244
|
+
reject(
|
|
245
|
+
new Error(`timed out waiting for an event after ${timeoutMs}ms`)
|
|
246
|
+
);
|
|
247
|
+
}, timeoutMs);
|
|
248
|
+
timer.unref?.();
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Resolves when the socket closes, e.g. after a protocol rejection. */
|
|
253
|
+
public async closed(): Promise<{
|
|
254
|
+
readonly code: number;
|
|
255
|
+
readonly reason: string;
|
|
256
|
+
}> {
|
|
257
|
+
while (!this.closeInfo) {
|
|
258
|
+
await Bun.sleep(5);
|
|
259
|
+
}
|
|
260
|
+
return this.closeInfo;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Drops the socket without a close frame, the way a killed client would. */
|
|
264
|
+
public kill(): void {
|
|
265
|
+
this.socket?.close(4000, "probe killed");
|
|
266
|
+
this.socket = undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
public close(): void {
|
|
270
|
+
this.socket?.close();
|
|
271
|
+
this.socket = undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private receive(raw: string): void {
|
|
275
|
+
const frame = JSON.parse(raw) as ServerEvent;
|
|
276
|
+
if (frame.type === "replay") {
|
|
277
|
+
for (const event of frame.events) {
|
|
278
|
+
this.dispatch(event);
|
|
279
|
+
}
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
this.dispatch(frame);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private dispatch(event: ServerEvent): void {
|
|
286
|
+
this.events.push(event);
|
|
287
|
+
if (isDurableEvent(event)) {
|
|
288
|
+
this.seq = Math.max(this.seq, event.seq);
|
|
289
|
+
} else if (event.type === "attached") {
|
|
290
|
+
this.sessionId = event.sessionId;
|
|
291
|
+
} else if (event.type === "picker_invalidate") {
|
|
292
|
+
void this.files.refreshRelative();
|
|
293
|
+
} else if (event.type === "response") {
|
|
294
|
+
this.pending.get(event.id)?.resolve(event);
|
|
295
|
+
this.pending.delete(event.id);
|
|
296
|
+
}
|
|
297
|
+
this.options.onEvent?.(event);
|
|
298
|
+
for (const waiter of this.waiters) {
|
|
299
|
+
if (waiter.test(event)) {
|
|
300
|
+
this.waiters.delete(waiter);
|
|
301
|
+
waiter.resolve(event);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function itemsOf(response: ResponseEvent): readonly PickerItem[] {
|
|
308
|
+
if (!response.success) {
|
|
309
|
+
throw new Error(response.error ?? "picker query failed");
|
|
310
|
+
}
|
|
311
|
+
return response.items ?? [];
|
|
312
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { DaemonUnit } from "#core/shared/DaemonUnit";
|
|
2
|
+
import { Supervisor } from "#core/shared/Supervisor";
|
|
3
|
+
import { Updater, type UpdateOutcome } from "#core/shared/Updater";
|
|
4
|
+
import type { ServerEvent } from "#protocol/ServerEvent";
|
|
5
|
+
|
|
6
|
+
export type ReloaderDeps = {
|
|
7
|
+
/** To every connection: the restart takes all of them down together. */
|
|
8
|
+
readonly announce: (event: ServerEvent) => void;
|
|
9
|
+
/** Runs the update, reporting each step as it starts. */
|
|
10
|
+
readonly update?: (onStep: (label: string) => void) => Promise<UpdateOutcome>;
|
|
11
|
+
/** Takes this process down so the supervisor puts the new code up in its place. */
|
|
12
|
+
readonly shutdown?: () => Promise<void>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Re-raise SIGTERM rather than exiting: the handler stops the gateway and flushes the read cursors.
|
|
16
|
+
async function restartAndExit(): Promise<void> {
|
|
17
|
+
await Supervisor.restartSiblings(DaemonUnit);
|
|
18
|
+
process.kill(process.pid, "SIGTERM");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Runs the update-restart-return an operator asked for, announcing each phase. */
|
|
22
|
+
export class Reloader {
|
|
23
|
+
private readonly announce: (event: ServerEvent) => void;
|
|
24
|
+
private readonly update: (
|
|
25
|
+
onStep: (label: string) => void
|
|
26
|
+
) => Promise<UpdateOutcome>;
|
|
27
|
+
private readonly shutdown: () => Promise<void>;
|
|
28
|
+
private running: Promise<void> | undefined;
|
|
29
|
+
|
|
30
|
+
public constructor(deps: ReloaderDeps) {
|
|
31
|
+
this.announce = deps.announce;
|
|
32
|
+
this.update = deps.update ?? ((onStep) => Updater.run({ onStep }));
|
|
33
|
+
this.shutdown = deps.shutdown ?? restartAndExit;
|
|
34
|
+
this.running = undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The run already in flight, or a new one; two callers share one install. */
|
|
38
|
+
public start(): Promise<void> {
|
|
39
|
+
this.running ??= this.run().finally(() => {
|
|
40
|
+
this.running = undefined;
|
|
41
|
+
});
|
|
42
|
+
return this.running;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private async run(): Promise<void> {
|
|
46
|
+
let outcome: UpdateOutcome;
|
|
47
|
+
try {
|
|
48
|
+
outcome = await this.update((label) => {
|
|
49
|
+
this.announce({ type: "update_state", phase: "step", label });
|
|
50
|
+
});
|
|
51
|
+
} catch (err) {
|
|
52
|
+
this.failed(messageOf(err));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!outcome.ok) {
|
|
56
|
+
this.failed(outcome.error ?? "the update failed");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const { from, to, skipped } = outcome;
|
|
60
|
+
// Unsupervised, exiting would end this process rather than replace it.
|
|
61
|
+
if (!Supervisor.isSupervised()) {
|
|
62
|
+
this.announce({
|
|
63
|
+
type: "update_state",
|
|
64
|
+
phase: "stranded",
|
|
65
|
+
from,
|
|
66
|
+
to,
|
|
67
|
+
skipped,
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
this.announce({
|
|
72
|
+
type: "update_state",
|
|
73
|
+
phase: "restarting",
|
|
74
|
+
from,
|
|
75
|
+
to,
|
|
76
|
+
skipped,
|
|
77
|
+
});
|
|
78
|
+
try {
|
|
79
|
+
await this.shutdown();
|
|
80
|
+
} catch (err) {
|
|
81
|
+
// Must follow the `restarting` frame: it corrects a client already told to expect the drop.
|
|
82
|
+
this.failed(messageOf(err));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private failed(error: string): void {
|
|
87
|
+
this.announce({ type: "update_state", phase: "failed", error });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function messageOf(err: unknown): string {
|
|
92
|
+
return (err as Error).message ?? String(err);
|
|
93
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { type Static, Type } from "typebox";
|
|
4
|
+
|
|
5
|
+
import type { AttachmentStore } from "#core/attachments/AttachmentStore";
|
|
6
|
+
import { Paths } from "#core/shared/Paths";
|
|
7
|
+
import { SendFile } from "#core/shared/SendFile";
|
|
8
|
+
import type { PimToolDefinition } from "#core/shared/Tools";
|
|
9
|
+
import type { ToolView } from "#core/view/ViewBlock";
|
|
10
|
+
import { attachmentUrl } from "./AttachmentEndpoint";
|
|
11
|
+
|
|
12
|
+
const sendFileSchema = Type.Object({
|
|
13
|
+
path: Type.String({
|
|
14
|
+
minLength: 1,
|
|
15
|
+
description: "Absolute or relative path to file (resolved against cwd).",
|
|
16
|
+
}),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
type SendFileInput = Static<typeof sendFileSchema>;
|
|
20
|
+
|
|
21
|
+
type SendFileDetails = {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly url: string;
|
|
24
|
+
readonly isImage: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type SendFileDeps = {
|
|
28
|
+
readonly store: AttachmentStore;
|
|
29
|
+
readonly cwd: string;
|
|
30
|
+
/** Pi's session uuid, read at call time. */
|
|
31
|
+
readonly sessionId: () => string | undefined;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function build(
|
|
35
|
+
deps: SendFileDeps
|
|
36
|
+
): PimToolDefinition<typeof sendFileSchema, SendFileDetails> {
|
|
37
|
+
return {
|
|
38
|
+
...defineTool({
|
|
39
|
+
name: "send_file",
|
|
40
|
+
label: "send_file",
|
|
41
|
+
description: `Send a local file to the user's web browser. Images appear inline. Max ${SendFile.MAX_BYTES / (1024 * 1024)} MB.`,
|
|
42
|
+
parameters: sendFileSchema,
|
|
43
|
+
async execute(_id, params) {
|
|
44
|
+
const { path: rawPath } = params as SendFileInput;
|
|
45
|
+
const { path, size } = await SendFile.validate(rawPath, deps.cwd);
|
|
46
|
+
const scope = deps.sessionId();
|
|
47
|
+
if (!scope) {
|
|
48
|
+
throw new Error("This session cannot send files yet.");
|
|
49
|
+
}
|
|
50
|
+
const stored = await deps.store.storeFile(scope, path);
|
|
51
|
+
const details: SendFileDetails = {
|
|
52
|
+
name: basename(path),
|
|
53
|
+
url: attachmentUrl(stored.path),
|
|
54
|
+
isImage: stored.mimeType.startsWith("image/"),
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
// Never the URL: an address the model can repeat is a second, dead copy of this delivery.
|
|
58
|
+
content: [{ type: "text", text: `Sent ${details.name} (${size} B)` }],
|
|
59
|
+
details,
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
effect: { kind: "readOnly" },
|
|
64
|
+
toViewModel: ({ args, result, cwd }): ToolView => {
|
|
65
|
+
const details = result?.details;
|
|
66
|
+
return {
|
|
67
|
+
label: "Send File",
|
|
68
|
+
icon: "upload",
|
|
69
|
+
title: [
|
|
70
|
+
{
|
|
71
|
+
kind: "file",
|
|
72
|
+
path: Paths.titleOr(
|
|
73
|
+
(args as Partial<SendFileInput> | undefined)?.path,
|
|
74
|
+
cwd
|
|
75
|
+
),
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
...(details === undefined
|
|
79
|
+
? {}
|
|
80
|
+
: {
|
|
81
|
+
summary: [{ kind: "attachment", ...details }],
|
|
82
|
+
}),
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const SendFileTool = { build };
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { EventLog } from "#core/session/EventLog";
|
|
2
|
+
import type { SessionDigest } from "#core/session/EventLog";
|
|
3
|
+
import type { ReadCursors } from "#core/session/ReadCursors";
|
|
4
|
+
import type { SessionRegistry } from "#core/session/SessionRegistry";
|
|
5
|
+
import { FileWatch } from "#core/shared/FileWatch";
|
|
6
|
+
import type { Command } from "#protocol/Command";
|
|
7
|
+
import type {
|
|
8
|
+
ServerEvent,
|
|
9
|
+
SessionStatus,
|
|
10
|
+
SessionSummaryView,
|
|
11
|
+
} from "#protocol/ServerEvent";
|
|
12
|
+
|
|
13
|
+
export type SessionCatalogueDeps = {
|
|
14
|
+
readonly registry: SessionRegistry;
|
|
15
|
+
/** Which sessions have been read, shared by every client. */
|
|
16
|
+
readonly cursors: ReadCursors;
|
|
17
|
+
readonly liveStatus: (sessionId: string) => SessionStatus | undefined;
|
|
18
|
+
readonly liveSessionIds: () => Iterable<string>;
|
|
19
|
+
readonly isBeingRead: (sessionId: string) => boolean;
|
|
20
|
+
readonly announce: (event: ServerEvent) => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const DEFAULT_SESSION_LIMIT = 50;
|
|
24
|
+
|
|
25
|
+
/** Long enough that a turn's entries are one announcement, short enough that a new session appears while the user is still looking. */
|
|
26
|
+
const ANNOUNCE_MS = 500;
|
|
27
|
+
|
|
28
|
+
type CachedDigest = SessionDigest & { readonly modifiedAt: number };
|
|
29
|
+
|
|
30
|
+
export class SessionCatalogue {
|
|
31
|
+
private readonly deps: SessionCatalogueDeps;
|
|
32
|
+
private readonly digests = new Map<string, CachedDigest>();
|
|
33
|
+
private readonly activity = new Map<string, SessionStatus>();
|
|
34
|
+
private readonly settled = new Map<string, number>();
|
|
35
|
+
private readonly watches = new Map<string, () => void>();
|
|
36
|
+
private watching = false;
|
|
37
|
+
private announcing: ReturnType<typeof setTimeout> | undefined;
|
|
38
|
+
|
|
39
|
+
public constructor(deps: SessionCatalogueDeps) {
|
|
40
|
+
this.deps = deps;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Follow the sessions tree while anyone is connected. A session the terminal
|
|
45
|
+
* or a vanilla `pi` starts is a file this process never hears about
|
|
46
|
+
* otherwise, and a list nobody asked to re-fetch is a list that never learns
|
|
47
|
+
* about it.
|
|
48
|
+
*/
|
|
49
|
+
public watch(active: boolean): void {
|
|
50
|
+
if (active === this.watching) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
this.watching = active;
|
|
54
|
+
if (!active) {
|
|
55
|
+
for (const stop of this.watches.values()) {
|
|
56
|
+
stop();
|
|
57
|
+
}
|
|
58
|
+
this.watches.clear();
|
|
59
|
+
clearTimeout(this.announcing);
|
|
60
|
+
this.announcing = undefined;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
this.follow();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
public async list(
|
|
67
|
+
command: Command & { readonly type: "list_sessions" }
|
|
68
|
+
): Promise<readonly SessionSummaryView[]> {
|
|
69
|
+
const summaries = await this.deps.registry.list(command.cwd);
|
|
70
|
+
// Prune only on an unfiltered listing: a cwd-filtered one would forget every other directory.
|
|
71
|
+
if (command.cwd === undefined) {
|
|
72
|
+
await this.deps.cursors.prune(
|
|
73
|
+
new Set([
|
|
74
|
+
...summaries.map((summary) => summary.sessionId),
|
|
75
|
+
...this.deps.liveSessionIds(),
|
|
76
|
+
])
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
// Cut by modified time and digest only the page: ordering by settle time would read every session on disk.
|
|
80
|
+
const page = await Promise.all(
|
|
81
|
+
summaries
|
|
82
|
+
.slice(0, command.limit ?? DEFAULT_SESSION_LIMIT)
|
|
83
|
+
.map(async ({ sessionId, cwd, path, createdAt, modifiedAt }) => {
|
|
84
|
+
const { title, settledAt } = await this.digestOf(path, modifiedAt);
|
|
85
|
+
const status = this.deps.liveStatus(sessionId);
|
|
86
|
+
const answeredAt = this.answerTime(sessionId, status, settledAt);
|
|
87
|
+
const unread = await this.deps.cursors.isUnread(
|
|
88
|
+
sessionId,
|
|
89
|
+
answeredAt
|
|
90
|
+
);
|
|
91
|
+
return {
|
|
92
|
+
sessionId,
|
|
93
|
+
cwd,
|
|
94
|
+
createdAt,
|
|
95
|
+
settledAt: answeredAt ?? settledAt ?? createdAt,
|
|
96
|
+
...(title === undefined ? {} : { title }),
|
|
97
|
+
...(status === undefined || status === "idle" ? {} : { status }),
|
|
98
|
+
...(unread ? { unread: true } : {}),
|
|
99
|
+
};
|
|
100
|
+
})
|
|
101
|
+
);
|
|
102
|
+
return page.sort((a, b) => b.settledAt - a.settledAt);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Announces a status edge to every client; the announcement must precede the read mark. */
|
|
106
|
+
public onStatus(sessionId: string, status: SessionStatus): void {
|
|
107
|
+
if (this.activity.get(sessionId) === status) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
this.activity.set(sessionId, status);
|
|
111
|
+
this.deps.announce({ type: "session_activity", sessionId, status });
|
|
112
|
+
if (status === "idle" && this.deps.isBeingRead(sessionId)) {
|
|
113
|
+
void this.markRead(sessionId);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
public track(sessionId: string, status: SessionStatus): void {
|
|
118
|
+
this.activity.set(sessionId, status);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Moves a session's read cursor to now and says so to every client. */
|
|
122
|
+
public async markRead(sessionId: string): Promise<void> {
|
|
123
|
+
await this.deps.cursors.mark(sessionId);
|
|
124
|
+
this.deps.announce({ type: "session_read", sessionId });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
public flush(): Promise<void> {
|
|
128
|
+
return this.deps.cursors.flush();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
public clear(): void {
|
|
132
|
+
this.digests.clear();
|
|
133
|
+
this.activity.clear();
|
|
134
|
+
this.settled.clear();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// One watch per directory, never a recursive one: `fs.watch` recursion is unsupported on some platforms and silent on others.
|
|
138
|
+
private follow(): void {
|
|
139
|
+
const root = this.deps.registry.sessionsRoot;
|
|
140
|
+
const wanted = new Set([root, ...FileWatch.subdirectories(root)]);
|
|
141
|
+
for (const [path, stop] of this.watches) {
|
|
142
|
+
if (!wanted.has(path)) {
|
|
143
|
+
stop();
|
|
144
|
+
this.watches.delete(path);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
for (const path of wanted) {
|
|
148
|
+
if (!this.watches.has(path)) {
|
|
149
|
+
this.watches.set(
|
|
150
|
+
path,
|
|
151
|
+
FileWatch.directory(path, () => {
|
|
152
|
+
this.onTreeChange();
|
|
153
|
+
})
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Debounced: a turn writes a dozen entries, and re-listing is a read of every session header.
|
|
160
|
+
private onTreeChange(): void {
|
|
161
|
+
if (this.announcing !== undefined || !this.watching) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
this.announcing = setTimeout(() => {
|
|
165
|
+
this.announcing = undefined;
|
|
166
|
+
this.follow();
|
|
167
|
+
this.deps.announce({ type: "sessions_changed" });
|
|
168
|
+
}, ANNOUNCE_MS);
|
|
169
|
+
this.announcing.unref?.();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// A running session must be answered for by its last idle reading: mid-turn the file's time is the line just written.
|
|
173
|
+
private answerTime(
|
|
174
|
+
sessionId: string,
|
|
175
|
+
status: SessionStatus | undefined,
|
|
176
|
+
fromFile: number | undefined
|
|
177
|
+
): number | undefined {
|
|
178
|
+
if (status === undefined) {
|
|
179
|
+
return fromFile;
|
|
180
|
+
}
|
|
181
|
+
if (status !== "idle") {
|
|
182
|
+
return this.settled.get(sessionId);
|
|
183
|
+
}
|
|
184
|
+
if (fromFile !== undefined) {
|
|
185
|
+
this.settled.set(sessionId, fromFile);
|
|
186
|
+
}
|
|
187
|
+
return fromFile;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private async digestOf(
|
|
191
|
+
path: string,
|
|
192
|
+
modifiedAt: number
|
|
193
|
+
): Promise<SessionDigest> {
|
|
194
|
+
const cached = this.digests.get(path);
|
|
195
|
+
if (cached?.modifiedAt === modifiedAt) {
|
|
196
|
+
return cached;
|
|
197
|
+
}
|
|
198
|
+
const digest = await new EventLog(path).digest();
|
|
199
|
+
this.digests.set(path, { ...digest, modifiedAt });
|
|
200
|
+
return digest;
|
|
201
|
+
}
|
|
202
|
+
}
|