@larose/pi-web 0.3.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.
Files changed (43) hide show
  1. package/LICENSE +235 -0
  2. package/README.md +50 -0
  3. package/THIRD_PARTY_LICENSES.md +40 -0
  4. package/dist/client/home.js +1619 -0
  5. package/dist/client/session.js +3703 -0
  6. package/dist/server/api.js +485 -0
  7. package/dist/server/cli.js +51 -0
  8. package/dist/server/directory-browser.js +104 -0
  9. package/dist/server/errors.js +10 -0
  10. package/dist/server/event-buffer.js +40 -0
  11. package/dist/server/extension-ui.js +245 -0
  12. package/dist/server/git-workspaces.js +559 -0
  13. package/dist/server/runtime-registry.js +703 -0
  14. package/dist/server/server.js +190 -0
  15. package/dist/server/session-repository.js +374 -0
  16. package/package.json +46 -0
  17. package/public/home.html +139 -0
  18. package/public/session.html +144 -0
  19. package/public/styles.css +2463 -0
  20. package/screenshots/home.png +0 -0
  21. package/screenshots/session.png +0 -0
  22. package/src/client/display-title.ts +36 -0
  23. package/src/client/event-stream.ts +194 -0
  24. package/src/client/home.ts +1575 -0
  25. package/src/client/markdown.ts +98 -0
  26. package/src/client/message-queue.ts +67 -0
  27. package/src/client/path-combobox.ts +271 -0
  28. package/src/client/session.ts +2174 -0
  29. package/src/client/shared.ts +99 -0
  30. package/src/client/slash-completion.ts +184 -0
  31. package/src/client/transcript-activity.ts +188 -0
  32. package/src/client/usage-format.ts +156 -0
  33. package/src/client/workspace-browser.ts +36 -0
  34. package/src/server/api.ts +652 -0
  35. package/src/server/cli.ts +63 -0
  36. package/src/server/directory-browser.ts +137 -0
  37. package/src/server/errors.ts +11 -0
  38. package/src/server/event-buffer.ts +59 -0
  39. package/src/server/extension-ui.ts +359 -0
  40. package/src/server/git-workspaces.ts +750 -0
  41. package/src/server/runtime-registry.ts +943 -0
  42. package/src/server/server.ts +248 -0
  43. package/src/server/session-repository.ts +488 -0
Binary file
Binary file
@@ -0,0 +1,36 @@
1
+ export const UNNAMED_SESSION_TITLE = "Unnamed session";
2
+ export const MAX_FALLBACK_TITLE_CHARS = 60;
3
+
4
+ export interface SessionTitleSource {
5
+ name?: string | undefined;
6
+ firstMessage?: string | undefined;
7
+ }
8
+
9
+ function normalizedFirstSentence(value: string): string {
10
+ const normalized = value.replace(/\s+/gu, " ").trim();
11
+ const sentenceEnd = /[.!?。!?](?=\s|$)/u.exec(normalized);
12
+ return sentenceEnd ? normalized.slice(0, sentenceEnd.index + sentenceEnd[0].length) : normalized;
13
+ }
14
+
15
+ function boundedTitle(value: string): string {
16
+ const characters = Array.from(value);
17
+ if (characters.length <= MAX_FALLBACK_TITLE_CHARS) {
18
+ return value;
19
+ }
20
+
21
+ const prefix = characters
22
+ .slice(0, MAX_FALLBACK_TITLE_CHARS - 1)
23
+ .join("")
24
+ .trimEnd();
25
+ return `${prefix}…`;
26
+ }
27
+
28
+ export function displaySessionTitle(source: SessionTitleSource): string {
29
+ const nativeName = source.name?.trim();
30
+ if (nativeName) {
31
+ return nativeName;
32
+ }
33
+
34
+ const firstSentence = normalizedFirstSentence(source.firstMessage ?? "");
35
+ return firstSentence ? boundedTitle(firstSentence) : UNNAMED_SESSION_TITLE;
36
+ }
@@ -0,0 +1,194 @@
1
+ export type EventStreamState = "connecting" | "connected" | "reconnecting";
2
+
3
+ export interface EventSourceLike {
4
+ onerror: ((event: Event) => void) | null;
5
+ addEventListener(type: string, listener: (event: Event) => void): void;
6
+ close(): void;
7
+ }
8
+
9
+ export interface EventStreamScheduler {
10
+ setTimeout(callback: () => void, delay: number): unknown;
11
+ clearTimeout(timer: unknown): void;
12
+ }
13
+
14
+ export interface EventStreamOptions {
15
+ createEventSource?: (url: string) => EventSourceLike;
16
+ heartbeatTimeoutMs?: number;
17
+ retryDelayMs?: number;
18
+ scheduler?: EventStreamScheduler;
19
+ onReady(event: MessageEvent<string>, recovered: boolean): void;
20
+ onRuntime(event: MessageEvent<string>): void;
21
+ onStateChange(state: EventStreamState): void;
22
+ }
23
+
24
+ const defaultScheduler: EventStreamScheduler = {
25
+ setTimeout: (callback, delay) => globalThis.setTimeout(callback, delay),
26
+ clearTimeout: (timer) => globalThis.clearTimeout(timer as ReturnType<typeof setTimeout>),
27
+ };
28
+
29
+ export class SessionEventStream {
30
+ private readonly createEventSource: (url: string) => EventSourceLike;
31
+ private readonly heartbeatTimeoutMs: number;
32
+ private readonly retryDelayMs: number;
33
+ private readonly scheduler: EventStreamScheduler;
34
+ private readonly onReady: EventStreamOptions["onReady"];
35
+ private readonly onRuntime: EventStreamOptions["onRuntime"];
36
+ private readonly onStateChange: EventStreamOptions["onStateChange"];
37
+ private source: EventSourceLike | undefined;
38
+ private sessionId: string | undefined;
39
+ private retryTimer: unknown;
40
+ private watchdogTimer: unknown;
41
+ private cursor: number | undefined;
42
+ private hasConnected = false;
43
+ private ready = false;
44
+
45
+ constructor(options: EventStreamOptions) {
46
+ this.createEventSource = options.createEventSource ?? ((url) => new EventSource(url));
47
+ this.heartbeatTimeoutMs = options.heartbeatTimeoutMs ?? 45_000;
48
+ this.retryDelayMs = options.retryDelayMs ?? 2_000;
49
+ this.scheduler = options.scheduler ?? defaultScheduler;
50
+ this.onReady = options.onReady;
51
+ this.onRuntime = options.onRuntime;
52
+ this.onStateChange = options.onStateChange;
53
+ }
54
+
55
+ get isReady(): boolean {
56
+ return this.ready;
57
+ }
58
+
59
+ start(sessionId: string): void {
60
+ this.stop();
61
+ this.sessionId = sessionId;
62
+ this.open("connecting");
63
+ }
64
+
65
+ stop(): void {
66
+ this.clearRetry();
67
+ this.clearWatchdog();
68
+ this.source?.close();
69
+ this.source = undefined;
70
+ this.sessionId = undefined;
71
+ this.cursor = undefined;
72
+ this.hasConnected = false;
73
+ this.ready = false;
74
+ }
75
+
76
+ reconnectNow(): void {
77
+ if (!this.sessionId) {
78
+ return;
79
+ }
80
+ this.open("reconnecting");
81
+ }
82
+
83
+ ensureConnected(): void {
84
+ if (!this.ready) {
85
+ this.reconnectNow();
86
+ }
87
+ }
88
+
89
+ private open(state: Exclude<EventStreamState, "connected">): void {
90
+ const sessionId = this.sessionId;
91
+ if (!sessionId) {
92
+ return;
93
+ }
94
+
95
+ this.clearRetry();
96
+ this.clearWatchdog();
97
+ this.source?.close();
98
+ this.ready = false;
99
+ this.onStateChange(state);
100
+
101
+ const path = `/api/sessions/${encodeURIComponent(sessionId)}/events`;
102
+ const url = this.cursor === undefined ? path : `${path}?lastEventId=${this.cursor}`;
103
+ const source = this.createEventSource(url);
104
+ this.source = source;
105
+ this.watch(source);
106
+
107
+ source.addEventListener("ready", (rawEvent) => {
108
+ if (source !== this.source) {
109
+ return;
110
+ }
111
+
112
+ this.markActivity(source);
113
+ this.ready = true;
114
+ const recovered = this.hasConnected;
115
+ this.hasConnected = true;
116
+ this.onStateChange("connected");
117
+ this.onReady(rawEvent as MessageEvent<string>, recovered);
118
+ });
119
+ source.addEventListener("runtime", (rawEvent) => {
120
+ if (source !== this.source) {
121
+ return;
122
+ }
123
+
124
+ this.markActivity(source);
125
+ const event = rawEvent as MessageEvent<string>;
126
+ const cursor = Number(event.lastEventId);
127
+ if (event.lastEventId && Number.isSafeInteger(cursor) && cursor >= 0) {
128
+ this.cursor = cursor;
129
+ }
130
+ this.onRuntime(event);
131
+ });
132
+ source.addEventListener("heartbeat", () => {
133
+ if (source === this.source) {
134
+ this.markActivity(source);
135
+ }
136
+ });
137
+ source.onerror = () => {
138
+ if (source === this.source) {
139
+ this.fail(source);
140
+ }
141
+ };
142
+ }
143
+
144
+ private fail(source: EventSourceLike): void {
145
+ if (source !== this.source) {
146
+ return;
147
+ }
148
+
149
+ source.close();
150
+ this.source = undefined;
151
+ this.ready = false;
152
+ this.clearWatchdog();
153
+ this.onStateChange("reconnecting");
154
+ this.clearRetry();
155
+ this.retryTimer = this.scheduler.setTimeout(() => {
156
+ this.retryTimer = undefined;
157
+ this.open("reconnecting");
158
+ }, this.retryDelayMs);
159
+ }
160
+
161
+ private markActivity(source: EventSourceLike): void {
162
+ if (source !== this.source) {
163
+ return;
164
+ }
165
+
166
+ this.clearWatchdog();
167
+ this.watch(source);
168
+ }
169
+
170
+ private watch(source: EventSourceLike): void {
171
+ this.watchdogTimer = this.scheduler.setTimeout(() => {
172
+ this.watchdogTimer = undefined;
173
+ if (source === this.source) {
174
+ this.open("reconnecting");
175
+ }
176
+ }, this.heartbeatTimeoutMs);
177
+ }
178
+
179
+ private clearRetry(): void {
180
+ if (this.retryTimer === undefined) {
181
+ return;
182
+ }
183
+ this.scheduler.clearTimeout(this.retryTimer);
184
+ this.retryTimer = undefined;
185
+ }
186
+
187
+ private clearWatchdog(): void {
188
+ if (this.watchdogTimer === undefined) {
189
+ return;
190
+ }
191
+ this.scheduler.clearTimeout(this.watchdogTimer);
192
+ this.watchdogTimer = undefined;
193
+ }
194
+ }