@pi-in-go/pigpen-ahp 0.1.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/CREDITS.md +27 -0
- package/LICENSE +23 -0
- package/README.md +148 -0
- package/extensions/ahp/access_options_test.go +116 -0
- package/extensions/ahp/extension.go +54 -0
- package/extensions/ahp/go.mod +12 -0
- package/extensions/ahp/go.sum +2 -0
- package/extensions/ahp/go.work +8 -0
- package/extensions/ahp/internal/channels/chat.go +110 -0
- package/extensions/ahp/internal/channels/root.go +32 -0
- package/extensions/ahp/internal/channels/session.go +128 -0
- package/extensions/ahp/internal/channels/session_test.go +108 -0
- package/extensions/ahp/internal/compose/compose.go +153 -0
- package/extensions/ahp/internal/compose/compose_test.go +120 -0
- package/extensions/ahp/internal/gaps/gaps_test.go +88 -0
- package/extensions/ahp/internal/host/handshake_test.go +206 -0
- package/extensions/ahp/internal/host/host.go +1305 -0
- package/extensions/ahp/internal/host/reconnect_test.go +197 -0
- package/extensions/ahp/internal/host/replaywindow_test.go +31 -0
- package/extensions/ahp/internal/host/schema_test.go +79 -0
- package/extensions/ahp/internal/host/store.go +291 -0
- package/extensions/ahp/internal/host/store_update_test.go +85 -0
- package/extensions/ahp/internal/host/subscriptions_test.go +160 -0
- package/extensions/ahp/internal/host/surface_test.go +95 -0
- package/extensions/ahp/internal/host/workarounds.go +458 -0
- package/extensions/ahp/internal/host/workarounds_test.go +96 -0
- package/extensions/ahp/internal/live/live.go +328 -0
- package/extensions/ahp/internal/live/live_test.go +91 -0
- package/extensions/ahp/internal/live/store.go +113 -0
- package/extensions/ahp/internal/mapper/activity.go +199 -0
- package/extensions/ahp/internal/mapper/activity_test.go +188 -0
- package/extensions/ahp/internal/mapper/bench_test.go +38 -0
- package/extensions/ahp/internal/mapper/event_mapper_test.go +380 -0
- package/extensions/ahp/internal/mapper/fixtures_test.go +387 -0
- package/extensions/ahp/internal/mapper/helpers_test.go +251 -0
- package/extensions/ahp/internal/mapper/js.go +131 -0
- package/extensions/ahp/internal/mapper/mapper.go +600 -0
- package/extensions/ahp/internal/mapper/message_input_test.go +196 -0
- package/extensions/ahp/internal/mapper/messageinput.go +272 -0
- package/extensions/ahp/internal/mapper/schema_helpers_test.go +5 -0
- package/extensions/ahp/internal/mapper/testdata/LICENSE-pi-ahp +21 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/abort.json +711 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/bash-long-output.json +1312 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/compaction.json +6285 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/parallel-tools.json +2622 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/plain-text.json +1856 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/single-tool.json +1128 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/steering.json +3926 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-bash.json +2372 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-edit.json +3983 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-error.json +2850 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-find.json +1162 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-grep.json +2783 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-loop.json +1693 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-ls.json +2427 -0
- package/extensions/ahp/internal/mapper/testdata/fixtures/tool-write.json +1271 -0
- package/extensions/ahp/internal/mapper/title.go +34 -0
- package/extensions/ahp/internal/mapper/usermsg.go +87 -0
- package/extensions/ahp/internal/pi/activeturn_test.go +296 -0
- package/extensions/ahp/internal/pi/backend.go +64 -0
- package/extensions/ahp/internal/pi/catalogue.go +370 -0
- package/extensions/ahp/internal/pi/catalogue_test.go +271 -0
- package/extensions/ahp/internal/pi/chatdriver.go +533 -0
- package/extensions/ahp/internal/pi/chatdriver_test.go +416 -0
- package/extensions/ahp/internal/pi/clientactions.go +212 -0
- package/extensions/ahp/internal/pi/clientactions_test.go +273 -0
- package/extensions/ahp/internal/pi/completions.go +230 -0
- package/extensions/ahp/internal/pi/completions_test.go +272 -0
- package/extensions/ahp/internal/pi/config_test.go +269 -0
- package/extensions/ahp/internal/pi/deletesession.go +69 -0
- package/extensions/ahp/internal/pi/deletesession_test.go +153 -0
- package/extensions/ahp/internal/pi/disposal_test.go +362 -0
- package/extensions/ahp/internal/pi/fixture_test.go +308 -0
- package/extensions/ahp/internal/pi/foreign_test.go +122 -0
- package/extensions/ahp/internal/pi/harness_test.go +83 -0
- package/extensions/ahp/internal/pi/history.go +269 -0
- package/extensions/ahp/internal/pi/hydrated_test.go +171 -0
- package/extensions/ahp/internal/pi/hydratedlifecycle_test.go +196 -0
- package/extensions/ahp/internal/pi/hydration_test.go +209 -0
- package/extensions/ahp/internal/pi/hydrator.go +212 -0
- package/extensions/ahp/internal/pi/imageinput.go +224 -0
- package/extensions/ahp/internal/pi/imageinput_test.go +57 -0
- package/extensions/ahp/internal/pi/lifecycle_test.go +381 -0
- package/extensions/ahp/internal/pi/models.go +212 -0
- package/extensions/ahp/internal/pi/models_test.go +128 -0
- package/extensions/ahp/internal/pi/paging.go +95 -0
- package/extensions/ahp/internal/pi/pagingtruncate_test.go +323 -0
- package/extensions/ahp/internal/pi/projecttrust.go +222 -0
- package/extensions/ahp/internal/pi/registry.go +924 -0
- package/extensions/ahp/internal/pi/restart_test.go +185 -0
- package/extensions/ahp/internal/pi/services.go +70 -0
- package/extensions/ahp/internal/pi/sessionconfig.go +64 -0
- package/extensions/ahp/internal/pi/sessionfiles_test.go +74 -0
- package/extensions/ahp/internal/pi/sessionstore.go +83 -0
- package/extensions/ahp/internal/pi/summary_test.go +426 -0
- package/extensions/ahp/internal/pi/workarounds_wire_test.go +208 -0
- package/extensions/ahp/internal/pisession/fromentries_test.go +32 -0
- package/extensions/ahp/internal/pisession/json.go +31 -0
- package/extensions/ahp/internal/pisession/pisession.go +580 -0
- package/extensions/ahp/internal/settings/access.go +63 -0
- package/extensions/ahp/internal/settings/access_test.go +52 -0
- package/extensions/ahp/internal/settings/settings.go +117 -0
- package/extensions/ahp/internal/settings/settings_test.go +104 -0
- package/extensions/ahp/internal/svc/etag_other.go +15 -0
- package/extensions/ahp/internal/svc/etag_unix.go +34 -0
- package/extensions/ahp/internal/svc/glob.go +197 -0
- package/extensions/ahp/internal/svc/mime.go +37 -0
- package/extensions/ahp/internal/svc/paths.go +108 -0
- package/extensions/ahp/internal/svc/pty.go +64 -0
- package/extensions/ahp/internal/svc/pty_darwin.go +42 -0
- package/extensions/ahp/internal/svc/pty_linux.go +36 -0
- package/extensions/ahp/internal/svc/pty_other.go +9 -0
- package/extensions/ahp/internal/svc/pty_test.go +114 -0
- package/extensions/ahp/internal/svc/pty_unix.go +147 -0
- package/extensions/ahp/internal/svc/resource.go +495 -0
- package/extensions/ahp/internal/svc/resource_test.go +501 -0
- package/extensions/ahp/internal/svc/stat_bsd.go +9 -0
- package/extensions/ahp/internal/svc/stat_linux.go +9 -0
- package/extensions/ahp/internal/svc/terminal.go +454 -0
- package/extensions/ahp/internal/svc/terminal_test.go +512 -0
- package/extensions/ahp/internal/svc/watch.go +590 -0
- package/extensions/ahp/internal/svc/watch_test.go +627 -0
- package/extensions/ahp/internal/svc/watchevents_test.go +452 -0
- package/extensions/ahp/internal/svc/watchpolicy.go +81 -0
- package/extensions/ahp/internal/svc/watchpolicy_test.go +151 -0
- package/extensions/ahp/internal/svc/watchracy_test.go +61 -0
- package/extensions/ahp/internal/testkit/schema/LICENSE-agent-host-protocol +21 -0
- package/extensions/ahp/internal/testkit/schema/actions.schema.json +9208 -0
- package/extensions/ahp/internal/testkit/schema/commands.schema.json +10862 -0
- package/extensions/ahp/internal/testkit/schema/errors.schema.json +10928 -0
- package/extensions/ahp/internal/testkit/schema/notifications.schema.json +6667 -0
- package/extensions/ahp/internal/testkit/schema/state.schema.json +6392 -0
- package/extensions/ahp/internal/testkit/schema.go +360 -0
- package/extensions/ahp/internal/testkit/testkit.go +367 -0
- package/extensions/ahp/internal/twin/twin.go +27 -0
- package/extensions/ahp/internal/wire/dispatchable.go +106 -0
- package/extensions/ahp/internal/wire/dispatchable_list.go +15 -0
- package/extensions/ahp/internal/wire/helpers_test.go +5 -0
- package/extensions/ahp/internal/wire/wire.go +218 -0
- package/extensions/ahp/internal/wire/wire_test.go +123 -0
- package/extensions/ahp/internal/ws/helpers_test.go +7 -0
- package/extensions/ahp/internal/ws/vscode_test.go +198 -0
- package/extensions/ahp/internal/ws/ws.go +674 -0
- package/extensions/ahp/internal/ws/ws_test.go +324 -0
- package/extensions/ahp/realpig_test.go +589 -0
- package/extensions/ahp/runtime.go +311 -0
- package/extensions/ahp/runtime_test.go +110 -0
- package/extensions/ahp/testdata/upstream-tests.json +3833 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/LICENSE +21 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/NOTICE-PIGPEN.md +18 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahp/client.go +1011 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahp/error.go +111 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahp/multi_host_state_mirror.go +239 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahp/reducers.go +1939 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahp/transport.go +176 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/actions.generated.go +2447 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/commands.generated.go +1546 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/common.go +205 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/errors.generated.go +65 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/messages.generated.go +138 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/notifications.generated.go +264 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/state.generated.go +6171 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/version.generated.go +31 -0
- package/extensions/ahp/third_party/agent-host-protocol-go/go.mod +3 -0
- package/extensions/ahp/twins_test.go +91 -0
- package/package.json +40 -0
- package/proof/PORT.md +121 -0
- package/proof/mutations.json +152 -0
- package/proof/oracle/LICENSE +21 -0
- package/proof/oracle/README.md +120 -0
- package/proof/oracle/UPSTREAM.md +7 -0
- package/proof/oracle/package.json +68 -0
- package/proof/oracle/src/bin/cli.ts +66 -0
- package/proof/oracle/src/bin/tunnel.ts +128 -0
- package/proof/oracle/src/channels/chat.ts +134 -0
- package/proof/oracle/src/channels/root.ts +39 -0
- package/proof/oracle/src/channels/session.ts +118 -0
- package/proof/oracle/src/channels/terminal.ts +11 -0
- package/proof/oracle/src/core/channels.ts +108 -0
- package/proof/oracle/src/core/client-workarounds.ts +358 -0
- package/proof/oracle/src/core/connection.ts +41 -0
- package/proof/oracle/src/core/host.ts +882 -0
- package/proof/oracle/src/core/sequencer.ts +75 -0
- package/proof/oracle/src/core/state-store.ts +162 -0
- package/proof/oracle/src/core/uri.ts +25 -0
- package/proof/oracle/src/host/direct-settings.ts +110 -0
- package/proof/oracle/src/host/pi-host.ts +220 -0
- package/proof/oracle/src/host/serve.ts +71 -0
- package/proof/oracle/src/host/terminal-service.ts +346 -0
- package/proof/oracle/src/pi/activity.ts +171 -0
- package/proof/oracle/src/pi/changeset-service.ts +646 -0
- package/proof/oracle/src/pi/changeset-uri.ts +64 -0
- package/proof/oracle/src/pi/chat-driver.ts +529 -0
- package/proof/oracle/src/pi/completions.ts +160 -0
- package/proof/oracle/src/pi/delete-session.ts +53 -0
- package/proof/oracle/src/pi/event-mapper.ts +648 -0
- package/proof/oracle/src/pi/git-changes.ts +605 -0
- package/proof/oracle/src/pi/history.ts +305 -0
- package/proof/oracle/src/pi/image-input.ts +56 -0
- package/proof/oracle/src/pi/image-mime.ts +8 -0
- package/proof/oracle/src/pi/in-process-backend.ts +155 -0
- package/proof/oracle/src/pi/message-input.ts +212 -0
- package/proof/oracle/src/pi/models.ts +124 -0
- package/proof/oracle/src/pi/project-trust.ts +66 -0
- package/proof/oracle/src/pi/provider.ts +2 -0
- package/proof/oracle/src/pi/resource-paths.ts +80 -0
- package/proof/oracle/src/pi/resource-service.ts +356 -0
- package/proof/oracle/src/pi/resource-watch-policy.ts +35 -0
- package/proof/oracle/src/pi/resource-watch.ts +357 -0
- package/proof/oracle/src/pi/session-catalogue.ts +318 -0
- package/proof/oracle/src/pi/session-config.ts +84 -0
- package/proof/oracle/src/pi/session-history.ts +75 -0
- package/proof/oracle/src/pi/session-hydrator.ts +219 -0
- package/proof/oracle/src/pi/session-registry.ts +815 -0
- package/proof/oracle/src/pi/session-storage.ts +29 -0
- package/proof/oracle/src/pi/session-title.ts +18 -0
- package/proof/oracle/src/pi/turn-paging.ts +94 -0
- package/proof/oracle/src/pi/user-message.ts +66 -0
- package/proof/oracle/src/protocol/errors.ts +44 -0
- package/proof/oracle/src/protocol/jsonrpc.ts +89 -0
- package/proof/oracle/src/protocol/version.ts +39 -0
- package/proof/oracle/src/transport/websocket.ts +136 -0
- package/proof/oracle/src/tunnel/devtunnel.ts +293 -0
- package/proof/oracle/src/tunnel/discovery.ts +37 -0
- package/proof/oracle/test/active-turn-reconnect.test.ts +274 -0
- package/proof/oracle/test/activity.test.ts +171 -0
- package/proof/oracle/test/changeset-lifecycle.test.serial.ts +303 -0
- package/proof/oracle/test/changeset-uri.test.ts +37 -0
- package/proof/oracle/test/changeset.test.serial.ts +487 -0
- package/proof/oracle/test/chat-driver.test.ts +691 -0
- package/proof/oracle/test/client-actions.test.ts +409 -0
- package/proof/oracle/test/client-workarounds.test.ts +320 -0
- package/proof/oracle/test/completions.test.ts +323 -0
- package/proof/oracle/test/delete-session.test.ts +142 -0
- package/proof/oracle/test/direct-settings.test.ts +81 -0
- package/proof/oracle/test/event-mapper.test.ts +619 -0
- package/proof/oracle/test/fetch-turns.test.ts +207 -0
- package/proof/oracle/test/fixtures/abort.json +711 -0
- package/proof/oracle/test/fixtures/bash-long-output.json +1312 -0
- package/proof/oracle/test/fixtures/compaction.json +6285 -0
- package/proof/oracle/test/fixtures/parallel-tools.json +2622 -0
- package/proof/oracle/test/fixtures/plain-text.json +1856 -0
- package/proof/oracle/test/fixtures/single-tool.json +1128 -0
- package/proof/oracle/test/fixtures/steering.json +3926 -0
- package/proof/oracle/test/fixtures/tool-bash.json +2372 -0
- package/proof/oracle/test/fixtures/tool-edit.json +3983 -0
- package/proof/oracle/test/fixtures/tool-error.json +2850 -0
- package/proof/oracle/test/fixtures/tool-find.json +1162 -0
- package/proof/oracle/test/fixtures/tool-grep.json +2783 -0
- package/proof/oracle/test/fixtures/tool-loop.json +1693 -0
- package/proof/oracle/test/fixtures/tool-ls.json +2427 -0
- package/proof/oracle/test/fixtures/tool-write.json +1271 -0
- package/proof/oracle/test/handshake.test.ts +275 -0
- package/proof/oracle/test/harness.ts +153 -0
- package/proof/oracle/test/hydrated-session-lifecycle.test.ts +211 -0
- package/proof/oracle/test/image-input.test.ts +28 -0
- package/proof/oracle/test/image-session.test.ts +69 -0
- package/proof/oracle/test/live-turn.test.ts +304 -0
- package/proof/oracle/test/mapper-fixtures.test.ts +293 -0
- package/proof/oracle/test/message-input.test.ts +181 -0
- package/proof/oracle/test/model-discovery.test.ts +170 -0
- package/proof/oracle/test/models.test.ts +109 -0
- package/proof/oracle/test/pi-host.test.ts +91 -0
- package/proof/oracle/test/pi-replay.test.ts +141 -0
- package/proof/oracle/test/project-trust.test.ts +125 -0
- package/proof/oracle/test/protocol-surface.test.ts +121 -0
- package/proof/oracle/test/pty.test.ts +100 -0
- package/proof/oracle/test/reconnect.test.ts +389 -0
- package/proof/oracle/test/resource-watch-policy.test.ts +78 -0
- package/proof/oracle/test/resource-watch.test.serial.ts +536 -0
- package/proof/oracle/test/resource.test.ts +499 -0
- package/proof/oracle/test/schema.test.ts +88 -0
- package/proof/oracle/test/session-catalogue.test.ts +314 -0
- package/proof/oracle/test/session-config.test.ts +224 -0
- package/proof/oracle/test/session-disposal.test.ts +364 -0
- package/proof/oracle/test/session-hydration.test.ts +194 -0
- package/proof/oracle/test/session-lifecycle.test.ts +416 -0
- package/proof/oracle/test/session-storage.test.serial.ts +32 -0
- package/proof/oracle/test/session-summary.test.ts +418 -0
- package/proof/oracle/test/subscriptions.test.ts +198 -0
- package/proof/oracle/test/support/assertions.ts +32 -0
- package/proof/oracle/test/support/async.ts +26 -0
- package/proof/oracle/test/support/hydrated-session.ts +205 -0
- package/proof/oracle/test/support/images.ts +8 -0
- package/proof/oracle/test/support/recorded-fixtures.ts +60 -0
- package/proof/oracle/test/support/recorded-scenarios.ts +145 -0
- package/proof/oracle/test/support/replay.ts +194 -0
- package/proof/oracle/test/support/schema.ts +221 -0
- package/proof/oracle/test/support/session-files.ts +12 -0
- package/proof/oracle/test/support/session-storage.ts +22 -0
- package/proof/oracle/test/support/upstream.ts +25 -0
- package/proof/oracle/test/support/watch-events.ts +108 -0
- package/proof/oracle/test/terminal-service.test.ts +495 -0
- package/proof/oracle/test/truncate.test.ts +230 -0
- package/proof/oracle/test/tunnel.test.ts +258 -0
- package/proof/oracle/test/upstream-workarounds.test.ts +37 -0
- package/proof/oracle/test/uri.test.ts +24 -0
- package/proof/oracle/test/watch-events.test.ts +118 -0
- package/proof/oracle/tsconfig.json +32 -0
- package/proof/proof-piglet/piglet.yaml +18 -0
- package/proof/tools/fakellm/go.mod +3 -0
- package/proof/tools/fakellm/main.go +109 -0
- package/proof/tools/gen-dispatchable.py +30 -0
- package/provenance.json +28 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem watchers — the `ahp-resource-watch:` channel.
|
|
3
|
+
*
|
|
4
|
+
* A watch has no dispose command. The protocol ties its lifetime to interest:
|
|
5
|
+
* the receiver releases it once its subscribers go away, after a grace window
|
|
6
|
+
* that lets a disconnected client reclaim the channel.
|
|
7
|
+
*
|
|
8
|
+
* The state itself never changes: `resourceWatchReducer` returns the same
|
|
9
|
+
* object for `resourceWatch/changed`. Changes are transient event traffic.
|
|
10
|
+
*
|
|
11
|
+
* @see https://microsoft.github.io/agent-host-protocol/specification/resource-watch-channel
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { realpath, stat } from "node:fs/promises";
|
|
16
|
+
import { dirname, join, resolve } from "node:path";
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
18
|
+
import {
|
|
19
|
+
ActionType,
|
|
20
|
+
AhpErrorCodes,
|
|
21
|
+
type CreateResourceWatchParams,
|
|
22
|
+
type CreateResourceWatchResult,
|
|
23
|
+
JsonRpcErrorCodes,
|
|
24
|
+
type ResourceChange,
|
|
25
|
+
ResourceChangeType,
|
|
26
|
+
type ResourceWatchState,
|
|
27
|
+
type URI,
|
|
28
|
+
} from "@microsoft/agent-host-protocol";
|
|
29
|
+
import { type FSWatcher, watch } from "chokidar";
|
|
30
|
+
import { RESOURCE_WATCH_SCHEME } from "../core/channels.ts";
|
|
31
|
+
import type { AhpHost } from "../core/host.ts";
|
|
32
|
+
import { ProtocolError } from "../protocol/errors.ts";
|
|
33
|
+
import { ResourcePathPolicy } from "./resource-paths.ts";
|
|
34
|
+
import { isExcluded, matchesPatterns, mergeChange, relativeWatchPath } from "./resource-watch-policy.ts";
|
|
35
|
+
|
|
36
|
+
export interface ResourceWatchOptions {
|
|
37
|
+
/** Shared access policy for the request/response and watch resource surfaces. */
|
|
38
|
+
readonly pathPolicy?: ResourcePathPolicy;
|
|
39
|
+
/** How long an unsubscribed watch remains available for reconnect. */
|
|
40
|
+
readonly graceMs?: number;
|
|
41
|
+
/** Maximum time to collect changes into one protocol action. */
|
|
42
|
+
readonly debounceMs?: number;
|
|
43
|
+
readonly log?: (message: string) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_GRACE_MS = 30_000;
|
|
47
|
+
const DEFAULT_DEBOUNCE_MS = 50;
|
|
48
|
+
|
|
49
|
+
interface ActiveWatch {
|
|
50
|
+
readonly channel: URI;
|
|
51
|
+
/** The path clients addressed; emitted event URIs retain this spelling. */
|
|
52
|
+
readonly resourceRoot: string;
|
|
53
|
+
/** The canonical path passed to chokidar. */
|
|
54
|
+
readonly watchedRoot: string;
|
|
55
|
+
readonly watcher: FSWatcher;
|
|
56
|
+
readonly excludes: readonly string[];
|
|
57
|
+
readonly includes: readonly string[];
|
|
58
|
+
pending: Map<string, ResourceChangeType>;
|
|
59
|
+
flushTimer: NodeJS.Timeout | undefined;
|
|
60
|
+
graceTimer: NodeJS.Timeout | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function errorCodeOf(error: unknown): string | undefined {
|
|
64
|
+
return (error as NodeJS.ErrnoException | undefined)?.code;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function watchError(error: unknown, uri: string): ProtocolError {
|
|
68
|
+
if (error instanceof ProtocolError) return error;
|
|
69
|
+
switch (errorCodeOf(error)) {
|
|
70
|
+
case "ENOENT":
|
|
71
|
+
return ProtocolError.notFound(uri);
|
|
72
|
+
case "EACCES":
|
|
73
|
+
case "EPERM":
|
|
74
|
+
return new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${uri}`);
|
|
75
|
+
case "EINVAL":
|
|
76
|
+
case "ENOTDIR":
|
|
77
|
+
return ProtocolError.invalidParams(`Cannot watch ${uri}`);
|
|
78
|
+
default:
|
|
79
|
+
return new ProtocolError(
|
|
80
|
+
JsonRpcErrorCodes.InternalError,
|
|
81
|
+
`Cannot watch ${uri}: ${error instanceof Error ? error.message : String(error)}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readPatterns(value: unknown, name: string): string[] {
|
|
87
|
+
if (value === undefined) return [];
|
|
88
|
+
if (typeof value !== "object" || value === null || !("items" in value)) {
|
|
89
|
+
throw ProtocolError.invalidParams(`${name} must contain an items array`);
|
|
90
|
+
}
|
|
91
|
+
const { items } = value;
|
|
92
|
+
if (!Array.isArray(items) || items.some((item) => typeof item !== "string")) {
|
|
93
|
+
throw ProtocolError.invalidParams(`${name}.items must be an array of strings`);
|
|
94
|
+
}
|
|
95
|
+
return [...items];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function waitUntilReady(watcher: FSWatcher): Promise<void> {
|
|
99
|
+
return new Promise((resolveReady, rejectReady) => {
|
|
100
|
+
const cleanup = (): void => {
|
|
101
|
+
watcher.removeListener("ready", onReady);
|
|
102
|
+
watcher.removeListener("error", onError);
|
|
103
|
+
};
|
|
104
|
+
const onReady = (): void => {
|
|
105
|
+
cleanup();
|
|
106
|
+
resolveReady();
|
|
107
|
+
};
|
|
108
|
+
const onError = (error: unknown): void => {
|
|
109
|
+
cleanup();
|
|
110
|
+
rejectReady(error);
|
|
111
|
+
};
|
|
112
|
+
watcher.once("ready", onReady);
|
|
113
|
+
watcher.once("error", onError);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Owns native watcher handles and their lifetime. `StateStore` remains the
|
|
119
|
+
* authority for protocol watch resources; this service retains only OS handles,
|
|
120
|
+
* filters, and unpublished event batches.
|
|
121
|
+
*/
|
|
122
|
+
export class ResourceWatchService {
|
|
123
|
+
readonly #host: AhpHost;
|
|
124
|
+
readonly #options: ResourceWatchOptions;
|
|
125
|
+
readonly #paths: ResourcePathPolicy;
|
|
126
|
+
readonly #watches = new Map<URI, ActiveWatch>();
|
|
127
|
+
readonly #unhook: () => void;
|
|
128
|
+
/** Native setup/close operations that shutdown must drain, not protocol state. */
|
|
129
|
+
readonly #pending = new Set<Promise<unknown>>();
|
|
130
|
+
#disposed = false;
|
|
131
|
+
#disposal: Promise<void> | undefined;
|
|
132
|
+
|
|
133
|
+
constructor(host: AhpHost, options: ResourceWatchOptions = {}) {
|
|
134
|
+
this.#host = host;
|
|
135
|
+
this.#options = options;
|
|
136
|
+
this.#paths = options.pathPolicy ?? new ResourcePathPolicy();
|
|
137
|
+
this.#unhook = host.onSubscriberCountChanged((channel, count) => {
|
|
138
|
+
this.#onSubscriberCount(channel, count);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Releases every native watcher. Safe to call more than once. */
|
|
143
|
+
dispose(): Promise<void> {
|
|
144
|
+
if (this.#disposal) return this.#disposal;
|
|
145
|
+
this.#disposed = true;
|
|
146
|
+
this.#unhook();
|
|
147
|
+
const releases = [...this.#watches.keys()].map((channel) => this.#release(channel));
|
|
148
|
+
this.#disposal = Promise.allSettled([...this.#pending, ...releases]).then(() => {});
|
|
149
|
+
return this.#disposal;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
get activeCount(): number {
|
|
153
|
+
return this.#watches.size;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
create(params: CreateResourceWatchParams): Promise<CreateResourceWatchResult> {
|
|
157
|
+
return this.#track(this.#create(params));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#track<T>(operation: Promise<T>): Promise<T> {
|
|
161
|
+
const pending = operation.finally(() => this.#pending.delete(pending));
|
|
162
|
+
this.#pending.add(pending);
|
|
163
|
+
return pending;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async #create(params: CreateResourceWatchParams): Promise<CreateResourceWatchResult> {
|
|
167
|
+
if (this.#disposed) {
|
|
168
|
+
throw new Error("ResourceWatchService is disposed");
|
|
169
|
+
}
|
|
170
|
+
if (!params || typeof params !== "object") {
|
|
171
|
+
throw ProtocolError.invalidParams("createResourceWatch params must be an object");
|
|
172
|
+
}
|
|
173
|
+
if (params.recursive !== undefined && typeof params.recursive !== "boolean") {
|
|
174
|
+
throw ProtocolError.invalidParams("recursive must be a boolean");
|
|
175
|
+
}
|
|
176
|
+
const uri = params.uri;
|
|
177
|
+
const recursive = params.recursive ?? false;
|
|
178
|
+
const excludes = readPatterns(params.excludes, "excludes");
|
|
179
|
+
const includes = readPatterns(params.includes, "includes");
|
|
180
|
+
const resourceRoot = await this.#paths.pathFor(uri);
|
|
181
|
+
|
|
182
|
+
let directory: boolean;
|
|
183
|
+
let watchedRoot: string;
|
|
184
|
+
try {
|
|
185
|
+
const stats = await stat(resourceRoot);
|
|
186
|
+
directory = stats.isDirectory();
|
|
187
|
+
if (!directory && recursive) {
|
|
188
|
+
throw ProtocolError.invalidParams(`Cannot watch a file recursively: ${uri}`);
|
|
189
|
+
}
|
|
190
|
+
// Watch the stable target but map events back to the URI spelling the
|
|
191
|
+
// client supplied. This makes a directory symlink usable without letting
|
|
192
|
+
// recursive watches follow further symlinks out of the permitted tree.
|
|
193
|
+
watchedRoot = await realpath(resourceRoot);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
throw watchError(error, uri);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (this.#disposed) throw new Error("ResourceWatchService is disposed");
|
|
199
|
+
|
|
200
|
+
// Starting from the parent keeps the watch alive when an editor replaces a
|
|
201
|
+
// file—or the watched directory itself—by rename. The ignored predicate
|
|
202
|
+
// prevents siblings from entering chokidar's watched tree.
|
|
203
|
+
const watchRoot = dirname(watchedRoot);
|
|
204
|
+
const depth = !directory ? 0 : recursive ? undefined : watchRoot === watchedRoot ? 0 : 1;
|
|
205
|
+
// Keep Chokidar's default persistent watcher: overlapping watches then
|
|
206
|
+
// share native handles and forward asynchronous watcher errors. Polling or
|
|
207
|
+
// `awaitWriteFinish` would change delivery timing rather than add protocol
|
|
208
|
+
// state guarantees.
|
|
209
|
+
const watcher = watch(watchRoot, {
|
|
210
|
+
atomic: true,
|
|
211
|
+
followSymlinks: false,
|
|
212
|
+
ignoreInitial: true,
|
|
213
|
+
...(depth === undefined ? {} : { depth }),
|
|
214
|
+
ignored: (path: string) => {
|
|
215
|
+
if (resolve(path) === watchRoot) return false;
|
|
216
|
+
const rel = relativeWatchPath(watchedRoot, path);
|
|
217
|
+
return rel === undefined || isExcluded(rel, excludes);
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
try {
|
|
221
|
+
// Let startup settle before closing: closing a not-yet-ready watcher
|
|
222
|
+
// does not settle its ready waiter. Shutdown drains this creation task.
|
|
223
|
+
await waitUntilReady(watcher);
|
|
224
|
+
if (this.#disposed) throw new Error("ResourceWatchService is disposed");
|
|
225
|
+
} catch (error) {
|
|
226
|
+
await watcher.close();
|
|
227
|
+
throw watchError(error, uri);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const channel: URI = `${RESOURCE_WATCH_SCHEME}/${randomUUID()}`;
|
|
231
|
+
const active: ActiveWatch = {
|
|
232
|
+
channel,
|
|
233
|
+
resourceRoot,
|
|
234
|
+
watchedRoot,
|
|
235
|
+
watcher,
|
|
236
|
+
excludes,
|
|
237
|
+
includes,
|
|
238
|
+
pending: new Map(),
|
|
239
|
+
flushTimer: undefined,
|
|
240
|
+
graceTimer: undefined,
|
|
241
|
+
};
|
|
242
|
+
const record = (path: string, type: ResourceChangeType): void => this.#record(active, path, type);
|
|
243
|
+
watcher
|
|
244
|
+
.on("add", (path) => record(path, ResourceChangeType.Added))
|
|
245
|
+
.on("addDir", (path) => record(path, ResourceChangeType.Added))
|
|
246
|
+
.on("change", (path) => record(path, ResourceChangeType.Updated))
|
|
247
|
+
.on("unlink", (path) => record(path, ResourceChangeType.Deleted))
|
|
248
|
+
.on("unlinkDir", (path) => record(path, ResourceChangeType.Deleted))
|
|
249
|
+
.on("error", (error) => {
|
|
250
|
+
this.#options.log?.(`watch ${channel} failed: ${String(error)}`);
|
|
251
|
+
void this.#release(channel);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const state: ResourceWatchState = {
|
|
255
|
+
root: uri,
|
|
256
|
+
recursive,
|
|
257
|
+
...(excludes.length > 0 ? { excludes: { items: [...excludes] } } : {}),
|
|
258
|
+
...(includes.length > 0 ? { includes: { items: [...includes] } } : {}),
|
|
259
|
+
};
|
|
260
|
+
this.#host.store.create(channel, state as never);
|
|
261
|
+
this.#watches.set(channel, active);
|
|
262
|
+
|
|
263
|
+
// The client subscribes after this request returns. Start a grace timer so
|
|
264
|
+
// an abandoned create cannot retain filesystem handles forever.
|
|
265
|
+
this.#startGrace(active);
|
|
266
|
+
return { channel };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── Change collection ───────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
#record(active: ActiveWatch, watchedPath: string, type: ResourceChangeType): void {
|
|
272
|
+
if (this.#watches.get(active.channel) !== active) return;
|
|
273
|
+
const rel = relativeWatchPath(active.watchedRoot, watchedPath);
|
|
274
|
+
if (rel === undefined || !matchesPatterns(rel, active.includes, active.excludes)) return;
|
|
275
|
+
|
|
276
|
+
const path = rel.length === 0 ? active.resourceRoot : join(active.resourceRoot, rel);
|
|
277
|
+
const merged = mergeChange(active.pending.get(path), type);
|
|
278
|
+
if (merged === undefined) {
|
|
279
|
+
active.pending.delete(path);
|
|
280
|
+
} else {
|
|
281
|
+
active.pending.set(path, merged);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (active.pending.size === 0) {
|
|
285
|
+
if (active.flushTimer) clearTimeout(active.flushTimer);
|
|
286
|
+
active.flushTimer = undefined;
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (active.flushTimer) return;
|
|
290
|
+
const timer = setTimeout(() => {
|
|
291
|
+
active.flushTimer = undefined;
|
|
292
|
+
this.#flush(active);
|
|
293
|
+
}, this.#options.debounceMs ?? DEFAULT_DEBOUNCE_MS);
|
|
294
|
+
timer.unref?.();
|
|
295
|
+
active.flushTimer = timer;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#flush(active: ActiveWatch): void {
|
|
299
|
+
if (this.#watches.get(active.channel) !== active) {
|
|
300
|
+
active.pending.clear();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const items: ResourceChange[] = [...active.pending]
|
|
304
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
305
|
+
.map(([path, type]) => ({ uri: pathToFileURL(path).toString(), type }));
|
|
306
|
+
active.pending.clear();
|
|
307
|
+
if (items.length === 0) return;
|
|
308
|
+
|
|
309
|
+
this.#host.dispatchServerAction(active.channel, {
|
|
310
|
+
type: ActionType.ResourceWatchChanged,
|
|
311
|
+
changes: { items },
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Lifetime ────────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
#onSubscriberCount(channel: URI, count: number): void {
|
|
318
|
+
const active = this.#watches.get(channel);
|
|
319
|
+
if (!active) return;
|
|
320
|
+
if (count > 0) {
|
|
321
|
+
if (active.graceTimer) {
|
|
322
|
+
clearTimeout(active.graceTimer);
|
|
323
|
+
active.graceTimer = undefined;
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
this.#startGrace(active);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
#startGrace(active: ActiveWatch): void {
|
|
331
|
+
if (active.graceTimer) return;
|
|
332
|
+
const timer = setTimeout(() => {
|
|
333
|
+
active.graceTimer = undefined;
|
|
334
|
+
if (this.#host.subscriberCount(active.channel) === 0) {
|
|
335
|
+
this.#options.log?.(`releasing unwatched ${active.channel}`);
|
|
336
|
+
void this.#release(active.channel);
|
|
337
|
+
}
|
|
338
|
+
}, this.#options.graceMs ?? DEFAULT_GRACE_MS);
|
|
339
|
+
timer.unref?.();
|
|
340
|
+
active.graceTimer = timer;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async #release(channel: URI): Promise<void> {
|
|
344
|
+
const active = this.#watches.get(channel);
|
|
345
|
+
if (!active) return;
|
|
346
|
+
this.#watches.delete(channel);
|
|
347
|
+
if (active.flushTimer) clearTimeout(active.flushTimer);
|
|
348
|
+
if (active.graceTimer) clearTimeout(active.graceTimer);
|
|
349
|
+
active.pending.clear();
|
|
350
|
+
this.#host.store.delete(channel);
|
|
351
|
+
try {
|
|
352
|
+
await this.#track(active.watcher.close());
|
|
353
|
+
} catch (error) {
|
|
354
|
+
this.#options.log?.(`closing watch ${channel} failed: ${String(error)}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session catalogue, backed by pi's on-disk session files.
|
|
3
|
+
*
|
|
4
|
+
* Production points it at pi's default per-directory JSONL tree under
|
|
5
|
+
* `getAgentDir()/sessions`; tests and embeddings provide their root explicitly.
|
|
6
|
+
* It does not resolve pi's optional custom `sessionDir` setting. Parsing a
|
|
7
|
+
* summary reads the session file, so `listSessions` stats and sorts the corpus
|
|
8
|
+
* but parses only the requested page.
|
|
9
|
+
*
|
|
10
|
+
* @see https://microsoft.github.io/agent-host-protocol/specification/root-channel
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { closeSync, openSync, readSync } from "node:fs";
|
|
14
|
+
import { readdir, stat } from "node:fs/promises";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { type ListSessionsResult, SessionStatus, type SessionSummary, type URI } from "@microsoft/agent-host-protocol";
|
|
18
|
+
import { sessionUri } from "../core/channels.ts";
|
|
19
|
+
import { pathToFileUri } from "../core/uri.ts";
|
|
20
|
+
import { ProtocolError } from "../protocol/errors.ts";
|
|
21
|
+
import { PI_PROVIDER } from "./provider.ts";
|
|
22
|
+
import { sessionDisplayTitle } from "./session-title.ts";
|
|
23
|
+
import { textFromPiUserContent } from "./user-message.ts";
|
|
24
|
+
|
|
25
|
+
/** Page size when the client does not ask for one. */
|
|
26
|
+
const DEFAULT_PAGE_SIZE = 30;
|
|
27
|
+
/** Upper bound regardless of what the client asks for; each entry costs a file parse. */
|
|
28
|
+
const MAX_PAGE_SIZE = 100;
|
|
29
|
+
/** Enough for pi's session header line, which is written first. */
|
|
30
|
+
const HEADER_SCAN_BYTES = 8192;
|
|
31
|
+
|
|
32
|
+
interface SessionFile {
|
|
33
|
+
readonly path: string;
|
|
34
|
+
readonly mtimeMs: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** In-memory summary for a session that may not have reached disk yet. */
|
|
38
|
+
export interface LiveSessionCatalogueEntry {
|
|
39
|
+
readonly file?: string;
|
|
40
|
+
readonly summary: SessionSummary;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Read lazily so a turn ending during disk discovery wins before the response is built. */
|
|
44
|
+
export type LiveSessionCatalogueSource = () => readonly LiveSessionCatalogueEntry[];
|
|
45
|
+
|
|
46
|
+
interface CatalogueEntry extends SessionFile {
|
|
47
|
+
/** Real file to parse; absent for a live session with no backing path. */
|
|
48
|
+
readonly file?: string;
|
|
49
|
+
/** Whether the file existed in this catalogue scan and is safe to cache. */
|
|
50
|
+
readonly onDisk?: boolean;
|
|
51
|
+
/** Present when live state is more authoritative than the file. */
|
|
52
|
+
readonly summary?: SessionSummary;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every session file with its mtime, newest first.
|
|
57
|
+
*
|
|
58
|
+
* `stat` only — no parsing. mtime is a good proxy for pi's own "last activity"
|
|
59
|
+
* ordering because the file is appended on every message.
|
|
60
|
+
*/
|
|
61
|
+
async function listSessionFiles(root: string): Promise<SessionFile[]> {
|
|
62
|
+
let directories: string[];
|
|
63
|
+
try {
|
|
64
|
+
directories = await readdir(root);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
// No sessions directory yet is an empty catalogue, not an error.
|
|
67
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const files: SessionFile[] = [];
|
|
74
|
+
await Promise.all(
|
|
75
|
+
directories.map(async (directory) => {
|
|
76
|
+
const directoryPath = join(root, directory);
|
|
77
|
+
let entries: string[];
|
|
78
|
+
try {
|
|
79
|
+
entries = await readdir(directoryPath);
|
|
80
|
+
} catch {
|
|
81
|
+
return; // A file (not a directory) at the top level, or a race with deletion.
|
|
82
|
+
}
|
|
83
|
+
await Promise.all(
|
|
84
|
+
entries
|
|
85
|
+
.filter((name) => name.endsWith(".jsonl"))
|
|
86
|
+
.map(async (name) => {
|
|
87
|
+
const path = join(directoryPath, name);
|
|
88
|
+
try {
|
|
89
|
+
const stats = await stat(path);
|
|
90
|
+
files.push({ path, mtimeMs: stats.mtimeMs });
|
|
91
|
+
} catch {
|
|
92
|
+
// Deleted between readdir and stat.
|
|
93
|
+
}
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Ties broken by path so the keyset cursor is total and stable.
|
|
100
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
101
|
+
return files;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Cursors are opaque to clients; this is just a keyset encoding. */
|
|
105
|
+
function encodeCursor(file: SessionFile): string {
|
|
106
|
+
return Buffer.from(`${file.mtimeMs}\u0000${file.path}`, "utf8").toString("base64url");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function decodeCursor(cursor: string): SessionFile {
|
|
110
|
+
const decoded = Buffer.from(cursor, "base64url").toString("utf8");
|
|
111
|
+
const separator = decoded.indexOf("\u0000");
|
|
112
|
+
const mtimeMs = Number(decoded.slice(0, separator));
|
|
113
|
+
const path = decoded.slice(separator + 1);
|
|
114
|
+
if (separator < 0 || !Number.isFinite(mtimeMs) || path.length === 0) {
|
|
115
|
+
throw ProtocolError.invalidParams("Unrecognised listSessions cursor");
|
|
116
|
+
}
|
|
117
|
+
return { path, mtimeMs };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Reads just the session id from a file's header line.
|
|
122
|
+
*
|
|
123
|
+
* pi writes the `session` header first, so this never parses the transcript.
|
|
124
|
+
*/
|
|
125
|
+
function readSessionId(path: string): string | undefined {
|
|
126
|
+
let handle: number | undefined;
|
|
127
|
+
try {
|
|
128
|
+
handle = openSync(path, "r");
|
|
129
|
+
const buffer = Buffer.allocUnsafe(HEADER_SCAN_BYTES);
|
|
130
|
+
const read = readSync(handle, buffer, 0, buffer.length, 0);
|
|
131
|
+
const newline = buffer.indexOf(0x0a, 0);
|
|
132
|
+
const line = buffer.toString("utf8", 0, newline >= 0 && newline < read ? newline : read);
|
|
133
|
+
const header = JSON.parse(line) as { type?: string; id?: string };
|
|
134
|
+
return header.type === "session" && typeof header.id === "string" ? header.id : undefined;
|
|
135
|
+
} catch {
|
|
136
|
+
return undefined;
|
|
137
|
+
} finally {
|
|
138
|
+
if (handle !== undefined) {
|
|
139
|
+
closeSync(handle);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Parses one session file into a summary.
|
|
146
|
+
*
|
|
147
|
+
* Returns `undefined` for a file that is not a readable pi session so a single
|
|
148
|
+
* corrupt file cannot break the whole catalogue.
|
|
149
|
+
*/
|
|
150
|
+
function readSessionSummary(file: SessionFile): SessionSummary | undefined {
|
|
151
|
+
let manager: SessionManager;
|
|
152
|
+
try {
|
|
153
|
+
manager = SessionManager.open(file.path);
|
|
154
|
+
} catch {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const sessionId = manager.getSessionId();
|
|
159
|
+
if (!sessionId) {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const entries = manager.getEntries();
|
|
164
|
+
let firstUserMessage: string | undefined;
|
|
165
|
+
let createdAt: string | undefined;
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
createdAt ??= entry.timestamp;
|
|
168
|
+
if (entry.type !== "message") {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const message = (entry as { message?: { role?: string; content?: unknown } }).message;
|
|
172
|
+
if (message?.role === "user") {
|
|
173
|
+
const text = textFromPiUserContent(message.content).trim();
|
|
174
|
+
if (text) {
|
|
175
|
+
firstUserMessage = text;
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const cwd = manager.getCwd();
|
|
182
|
+
return {
|
|
183
|
+
resource: sessionUri(sessionId),
|
|
184
|
+
provider: PI_PROVIDER,
|
|
185
|
+
title: sessionDisplayTitle(manager.getSessionName(), firstUserMessage),
|
|
186
|
+
// Idle by definition, and reported as read.
|
|
187
|
+
//
|
|
188
|
+
// Read/unread is not modelled: pi has no such concept, so tracking it
|
|
189
|
+
// would mean this host inventing durable state of its own — and without
|
|
190
|
+
// archiving to go with it, a catalogue where everything is permanently
|
|
191
|
+
// unread is worse than one that is quiet. The chat reducer may still clear
|
|
192
|
+
// its chat-local bit on `chat/turnStarted`; the session catalogue remains
|
|
193
|
+
// read because this host does not implement mutable session read state.
|
|
194
|
+
status: SessionStatus.Idle | SessionStatus.IsRead,
|
|
195
|
+
createdAt: createdAt ?? new Date(file.mtimeMs).toISOString(),
|
|
196
|
+
modifiedAt: new Date(file.mtimeMs).toISOString(),
|
|
197
|
+
...(cwd ? { workingDirectories: [pathToFileUri(cwd)] } : {}),
|
|
198
|
+
_meta: { piSessionFile: file.path },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Serves `listSessions`, newest first, parsing only the requested page.
|
|
204
|
+
*/
|
|
205
|
+
export class PiSessionCatalogue {
|
|
206
|
+
readonly #root: string;
|
|
207
|
+
/** Session URI → file path, populated as pages are read. Purely a cache. */
|
|
208
|
+
readonly #index = new Map<URI, string>();
|
|
209
|
+
constructor(root: string) {
|
|
210
|
+
this.#root = root;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The pi session file backing a URI, if a page has surfaced it. */
|
|
214
|
+
fileFor(uri: URI): string | undefined {
|
|
215
|
+
return this.#index.get(uri);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Locates the file backing a session id, scanning if necessary.
|
|
220
|
+
*
|
|
221
|
+
* A client can subscribe to a session it learned about on an earlier
|
|
222
|
+
* connection, so the in-memory index built while paging is not enough. The
|
|
223
|
+
* scan reads headers only — `stat` plus the first line — and stops at the
|
|
224
|
+
* first match, ordered newest-first so a recently-touched session is found
|
|
225
|
+
* almost immediately.
|
|
226
|
+
*/
|
|
227
|
+
async findSessionFile(sessionId: string): Promise<string | undefined> {
|
|
228
|
+
const uri = sessionUri(sessionId);
|
|
229
|
+
const cached = this.#index.get(uri);
|
|
230
|
+
if (cached) {
|
|
231
|
+
// The cache is only an index hint: deletion by this host, pi, or the
|
|
232
|
+
// user must invalidate a path before it is returned again.
|
|
233
|
+
if (readSessionId(cached) === sessionId) {
|
|
234
|
+
return cached;
|
|
235
|
+
}
|
|
236
|
+
this.#index.delete(uri);
|
|
237
|
+
}
|
|
238
|
+
for (const file of await listSessionFiles(this.#root)) {
|
|
239
|
+
const id = readSessionId(file.path);
|
|
240
|
+
if (id) {
|
|
241
|
+
this.#index.set(sessionUri(id), file.path);
|
|
242
|
+
}
|
|
243
|
+
if (id === sessionId) {
|
|
244
|
+
return file.path;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async list(
|
|
251
|
+
limit: number | undefined,
|
|
252
|
+
cursor: string | undefined,
|
|
253
|
+
live: LiveSessionCatalogueSource = () => [],
|
|
254
|
+
): Promise<ListSessionsResult> {
|
|
255
|
+
const pageSize = Math.min(Math.max(1, limit ?? DEFAULT_PAGE_SIZE), MAX_PAGE_SIZE);
|
|
256
|
+
const entries = new Map<string, CatalogueEntry>();
|
|
257
|
+
for (const file of await listSessionFiles(this.#root)) {
|
|
258
|
+
entries.set(file.path, { ...file, file: file.path, onDisk: true });
|
|
259
|
+
}
|
|
260
|
+
// Read live state after asynchronous disk discovery. From here through
|
|
261
|
+
// response construction there is no await, so a turn transition cannot
|
|
262
|
+
// make the returned page older than a notification sent before it.
|
|
263
|
+
for (const item of live()) {
|
|
264
|
+
const parsed = Date.parse(item.summary.modifiedAt);
|
|
265
|
+
const path = item.file ?? `live:${item.summary.resource}`;
|
|
266
|
+
const existing = entries.get(path);
|
|
267
|
+
entries.set(path, {
|
|
268
|
+
path,
|
|
269
|
+
mtimeMs: Number.isFinite(parsed) ? parsed : 0,
|
|
270
|
+
...(item.file ? { file: item.file } : {}),
|
|
271
|
+
...(existing?.onDisk ? { onDisk: true } : {}),
|
|
272
|
+
summary: item.summary,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
const ordered = [...entries.values()].sort(
|
|
276
|
+
(a, b) => b.mtimeMs - a.mtimeMs || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
let start = 0;
|
|
280
|
+
if (cursor !== undefined) {
|
|
281
|
+
const after = decodeCursor(cursor);
|
|
282
|
+
const index = ordered.findIndex((entry) => entry.path === after.path);
|
|
283
|
+
if (index < 0) {
|
|
284
|
+
// The entry the cursor named is gone (deleted between pages).
|
|
285
|
+
// Fall back to the position it would have occupied rather than
|
|
286
|
+
// failing the whole call.
|
|
287
|
+
start = ordered.findIndex(
|
|
288
|
+
(entry) => entry.mtimeMs < after.mtimeMs || (entry.mtimeMs === after.mtimeMs && entry.path > after.path),
|
|
289
|
+
);
|
|
290
|
+
if (start < 0) {
|
|
291
|
+
return { items: [] };
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
start = index + 1;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const page = ordered.slice(start, start + pageSize);
|
|
299
|
+
const items: SessionSummary[] = [];
|
|
300
|
+
for (const entry of page) {
|
|
301
|
+
const summary =
|
|
302
|
+
entry.summary ?? (entry.file ? readSessionSummary({ path: entry.file, mtimeMs: entry.mtimeMs }) : undefined);
|
|
303
|
+
if (summary) {
|
|
304
|
+
if (entry.file && entry.onDisk) {
|
|
305
|
+
this.#index.set(summary.resource, entry.file);
|
|
306
|
+
}
|
|
307
|
+
items.push(summary);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const last = page[page.length - 1];
|
|
312
|
+
const hasMore = start + page.length < ordered.length;
|
|
313
|
+
return {
|
|
314
|
+
items,
|
|
315
|
+
...(hasMore && last ? { nextCursor: encodeCursor(last) } : {}),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
}
|