pim-agent 0.9.0 → 0.10.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/bin/pim.ts +1 -1
- package/package.json +2 -2
- package/packages/core/src/extensions/CoreExtensions.ts +19 -16
- package/packages/core/src/extensions/apply-patch/render.ts +16 -55
- package/packages/core/src/extensions/system-prompt/index.ts +29 -21
- package/packages/core/src/extensions/system-prompt/prompt.ts +5 -1
- package/packages/core/src/picker/PickerService.ts +23 -1
- package/packages/core/src/picker/token.ts +12 -5
- package/packages/core/src/session/EventLog.ts +22 -79
- package/packages/core/src/session/SearchIndex.ts +752 -0
- package/packages/core/src/session/SearchTokens.ts +97 -0
- package/packages/core/src/session/SessionDigest.ts +161 -0
- package/packages/core/src/session/SessionHost.ts +17 -1
- package/packages/core/src/session/SessionMeta.ts +267 -0
- package/packages/core/src/session/SessionName.ts +18 -0
- package/packages/core/src/session/SessionRegistry.ts +89 -14
- package/packages/core/src/session/WriteMark.ts +15 -1
- package/packages/core/src/shared/DiffLines.ts +1 -1
- package/packages/core/src/shared/DiffPatch.ts +70 -0
- package/packages/core/src/shared/DiffRenderer.ts +1 -1
- package/packages/core/src/shared/FileWatch.ts +15 -1
- package/packages/core/src/shared/Git.ts +483 -42
- package/packages/core/src/shared/GitMonitor.ts +221 -0
- package/packages/core/src/shared/Levenshtein.ts +54 -1
- package/packages/core/src/shared/Proc.ts +21 -1
- package/packages/core/src/shared/RepoDiff.ts +741 -0
- package/packages/core/src/shared/Surface.ts +2 -0
- package/packages/core/src/shared/Updater.ts +21 -5
- package/packages/core/src/view/DiffExpand.ts +214 -0
- package/packages/core/src/view/DiffLayout.ts +7 -7
- package/packages/core/src/view/DiffPairs.ts +47 -0
- package/packages/core/src/view/MovePath.ts +70 -0
- package/packages/daemon/src/WebSurface.ts +1 -2
- package/packages/protocol/src/Command.ts +129 -2
- package/packages/protocol/src/Diff.ts +15 -0
- package/packages/protocol/src/ServerEvent.ts +157 -2
- package/packages/server/src/ClientConnection.ts +15 -3
- package/packages/server/src/ProbeClient.ts +90 -28
- package/packages/server/src/SessionCatalogue.ts +292 -38
- package/packages/server/src/SessionStream.ts +69 -43
- package/packages/server/src/StaticClient.ts +95 -12
- package/packages/server/src/WsGateway.ts +264 -29
- package/packages/server/src/probe.ts +61 -7
- package/packages/telegram/src/Session.ts +1 -0
- package/packages/tui/src/extensions/command-picker/index.ts +64 -32
- package/packages/tui/src/extensions/footer/index.ts +19 -43
- package/packages/tui/src/extensions/session-lease/index.ts +8 -3
- package/packages/web/dist/client/assets/commit-mono-latin-300-normal-B-iV2FbL.woff2 +0 -0
- package/packages/web/dist/client/assets/commit-mono-latin-300-normal-kM0OTZCv.woff +0 -0
- package/packages/web/dist/client/assets/{core-CGdQx9la.js → core-CA0aSzPu.js} +1 -1
- package/packages/web/dist/client/assets/index-B9be-i9d.css +1 -0
- package/packages/web/dist/client/assets/index-CpinM5NK.js +45 -0
- package/packages/web/dist/client/index.html +2 -2
- package/packages/protocol/src/Protocol.ts +0 -6
- package/packages/web/dist/client/assets/index-BMNwleWO.js +0 -40
- package/packages/web/dist/client/assets/index-CqjB2RPr.css +0 -1
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { join, resolve, sep } from "node:path";
|
|
2
|
+
import { brotliCompressSync, constants } from "node:zlib";
|
|
3
|
+
|
|
4
|
+
import type { BunFile } from "bun";
|
|
2
5
|
|
|
3
6
|
/** The built Vite bundle, resolved relative to this file so it works installed. */
|
|
4
7
|
export const DEFAULT_CLIENT_DIR = resolve(
|
|
@@ -18,6 +21,45 @@ const BUILD_HINT =
|
|
|
18
21
|
/** Vite fingerprints everything under `assets/`, so it can never go stale. */
|
|
19
22
|
export const IMMUTABLE = "public, max-age=31536000, immutable";
|
|
20
23
|
|
|
24
|
+
const COMPRESSIBLE =
|
|
25
|
+
/^(?:text\/|image\/svg\+xml|application\/(?:javascript|json|wasm|xml))/;
|
|
26
|
+
|
|
27
|
+
/** Below this a deflate stream is its own header: the framing costs more than the saving. */
|
|
28
|
+
const MIN_COMPRESSED = 1024;
|
|
29
|
+
|
|
30
|
+
type Bytes = Uint8Array<ArrayBuffer>;
|
|
31
|
+
|
|
32
|
+
type Encoding = {
|
|
33
|
+
readonly name: string;
|
|
34
|
+
readonly compress: (bytes: Bytes) => Bytes;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Best first. Brotli at q5 beats gzip -9 on both size and time; q11 would cost 40x the CPU for 8%. */
|
|
38
|
+
const ENCODINGS: readonly Encoding[] = [
|
|
39
|
+
{
|
|
40
|
+
name: "br",
|
|
41
|
+
compress: (bytes) =>
|
|
42
|
+
new Uint8Array(
|
|
43
|
+
brotliCompressSync(bytes, {
|
|
44
|
+
params: { [constants.BROTLI_PARAM_QUALITY]: 5 },
|
|
45
|
+
})
|
|
46
|
+
),
|
|
47
|
+
},
|
|
48
|
+
{ name: "gzip", compress: (bytes) => Bun.gzipSync(bytes) },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
type Encoded = { readonly stamp: string; readonly bytes: Bytes };
|
|
52
|
+
|
|
53
|
+
function accepts(header: string, name: string): boolean {
|
|
54
|
+
return header.split(",").some((entry) => {
|
|
55
|
+
const [token, ...params] = entry.trim().split(";");
|
|
56
|
+
return (
|
|
57
|
+
token?.toLowerCase() === name &&
|
|
58
|
+
!params.some((param) => param.replaceAll(" ", "") === "q=0")
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
21
63
|
/** A file off disk under a caching policy, or the 404 that a path naming nothing is. */
|
|
22
64
|
export async function serveFile(
|
|
23
65
|
path: string,
|
|
@@ -37,6 +79,7 @@ function looksLikeAsset(pathname: string): boolean {
|
|
|
37
79
|
/** Serves the built browser client as a static SPA; unknown routes fall back to `index.html`. */
|
|
38
80
|
export class StaticClient {
|
|
39
81
|
private readonly root: string;
|
|
82
|
+
private readonly encoded = new Map<string, Encoded>();
|
|
40
83
|
|
|
41
84
|
public constructor(root: string = DEFAULT_CLIENT_DIR) {
|
|
42
85
|
this.root = resolve(root);
|
|
@@ -48,13 +91,11 @@ export class StaticClient {
|
|
|
48
91
|
if (path !== undefined) {
|
|
49
92
|
const file = Bun.file(path);
|
|
50
93
|
if (await file.exists()) {
|
|
51
|
-
return
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
},
|
|
57
|
-
});
|
|
94
|
+
return await this.respond(
|
|
95
|
+
file,
|
|
96
|
+
request,
|
|
97
|
+
pathname.startsWith("/assets/") ? IMMUTABLE : "no-cache"
|
|
98
|
+
);
|
|
58
99
|
}
|
|
59
100
|
}
|
|
60
101
|
if (looksLikeAsset(pathname)) {
|
|
@@ -67,14 +108,56 @@ export class StaticClient {
|
|
|
67
108
|
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
68
109
|
});
|
|
69
110
|
}
|
|
70
|
-
return
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
111
|
+
return await this.respond(
|
|
112
|
+
index,
|
|
113
|
+
request,
|
|
114
|
+
"no-cache",
|
|
115
|
+
"text/html; charset=utf-8"
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// `vary` rides every answer, compressed or not: a cache must not hand one client's encoding to another.
|
|
120
|
+
private async respond(
|
|
121
|
+
file: BunFile,
|
|
122
|
+
request: Request,
|
|
123
|
+
cacheControl: string,
|
|
124
|
+
contentType = file.type
|
|
125
|
+
): Promise<Response> {
|
|
126
|
+
const headers: Record<string, string> = {
|
|
127
|
+
"content-type": contentType,
|
|
128
|
+
"cache-control": cacheControl,
|
|
129
|
+
vary: "accept-encoding",
|
|
130
|
+
};
|
|
131
|
+
const encoding = this.negotiate(file, request);
|
|
132
|
+
if (encoding === undefined) {
|
|
133
|
+
return new Response(file, { headers });
|
|
134
|
+
}
|
|
135
|
+
return new Response(await this.compress(file, encoding), {
|
|
136
|
+
headers: { ...headers, "content-encoding": encoding.name },
|
|
75
137
|
});
|
|
76
138
|
}
|
|
77
139
|
|
|
140
|
+
private negotiate(file: BunFile, request: Request): Encoding | undefined {
|
|
141
|
+
if (!COMPRESSIBLE.test(file.type) || file.size < MIN_COMPRESSED) {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
const accepted = request.headers.get("accept-encoding") ?? "";
|
|
145
|
+
return ENCODINGS.find((encoding) => accepts(accepted, encoding.name));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Keyed by name, stamped by mtime: a rebuilt bundle replaces its entry instead of adding one.
|
|
149
|
+
private async compress(file: BunFile, encoding: Encoding): Promise<Bytes> {
|
|
150
|
+
const key = `${file.name ?? ""}\0${encoding.name}`;
|
|
151
|
+
const stamp = `${file.lastModified}:${file.size}`;
|
|
152
|
+
const cached = this.encoded.get(key);
|
|
153
|
+
if (cached?.stamp === stamp) {
|
|
154
|
+
return cached.bytes;
|
|
155
|
+
}
|
|
156
|
+
const bytes = encoding.compress(await file.bytes());
|
|
157
|
+
this.encoded.set(key, { stamp, bytes });
|
|
158
|
+
return bytes;
|
|
159
|
+
}
|
|
160
|
+
|
|
78
161
|
// `new URL()` normalises literal `../` but not its percent-encoded spelling: re-resolve the decoded path.
|
|
79
162
|
private within(pathname: string): string | undefined {
|
|
80
163
|
let decoded: string;
|
|
@@ -7,16 +7,22 @@ import { Attachments } from "#core/attachments/Attachments";
|
|
|
7
7
|
import type { PickerItem } from "#core/picker/PickerItem";
|
|
8
8
|
import { Directories } from "#core/shared/Directories";
|
|
9
9
|
import type { DirectoryListing } from "#core/shared/Directories";
|
|
10
|
+
import { Git, type GitBranch } from "#core/shared/Git";
|
|
11
|
+
import { GitMonitor, type GitRun } from "#core/shared/GitMonitor";
|
|
12
|
+
import { RepoDiff } from "#core/shared/RepoDiff";
|
|
10
13
|
import { ReadCursors } from "#core/session/ReadCursors";
|
|
11
14
|
import type { SessionHost } from "#core/session/SessionHost";
|
|
15
|
+
import { SessionMeta } from "#core/session/SessionMeta";
|
|
12
16
|
import type { SessionRegistry } from "#core/session/SessionRegistry";
|
|
13
17
|
import { PimVersion } from "#core/shared/PimVersion";
|
|
14
18
|
import { SubagentLogs } from "#core/shared/SubagentLogs";
|
|
15
19
|
import type { UpdateOutcome } from "#core/shared/Updater";
|
|
16
20
|
import type { Command } from "#protocol/Command";
|
|
17
|
-
import {
|
|
21
|
+
import type { ChangeList, FileDiff, FileLines } from "#protocol/Diff";
|
|
18
22
|
import type {
|
|
19
23
|
ModelView,
|
|
24
|
+
ProjectView,
|
|
25
|
+
SearchHitView,
|
|
20
26
|
ServerEvent,
|
|
21
27
|
SessionSummaryView,
|
|
22
28
|
} from "#protocol/ServerEvent";
|
|
@@ -41,6 +47,10 @@ export type WsGatewayDeps = {
|
|
|
41
47
|
readonly imagesRoot?: string;
|
|
42
48
|
/** Where the read cursors live; defaults to `~/.pim/read.json`. */
|
|
43
49
|
readonly readCursorsPath?: string;
|
|
50
|
+
/** Where the per-session and per-project overrides live; defaults to `~/.pim/sessions.json`. */
|
|
51
|
+
readonly sessionMetaPath?: string;
|
|
52
|
+
/** How often a session file is polled where `fs.watch` says nothing; the default is a second. */
|
|
53
|
+
readonly pollMs?: number;
|
|
44
54
|
/** The built web client; defaults to the bundle shipped beside this package. */
|
|
45
55
|
readonly clientDir?: string;
|
|
46
56
|
/** Runs the update a `reload` asks for, reporting each step as it starts. */
|
|
@@ -53,9 +63,18 @@ type Outcome = {
|
|
|
53
63
|
readonly error?: string;
|
|
54
64
|
readonly items?: readonly PickerItem[];
|
|
55
65
|
readonly sessions?: readonly SessionSummaryView[];
|
|
66
|
+
readonly projects?: readonly ProjectView[];
|
|
67
|
+
readonly hits?: readonly SearchHitView[];
|
|
68
|
+
readonly dropped?: readonly string[];
|
|
69
|
+
readonly scanned?: number;
|
|
56
70
|
readonly models?: readonly ModelView[];
|
|
57
71
|
readonly thinkingLevels?: readonly string[];
|
|
58
72
|
readonly directory?: DirectoryListing;
|
|
73
|
+
readonly branches?: readonly GitBranch[];
|
|
74
|
+
readonly commit?: { readonly sha: string };
|
|
75
|
+
readonly changes?: ChangeList;
|
|
76
|
+
readonly fileDiff?: FileDiff;
|
|
77
|
+
readonly fileLines?: FileLines;
|
|
59
78
|
readonly restored?: readonly string[];
|
|
60
79
|
readonly after?: () => void;
|
|
61
80
|
};
|
|
@@ -81,6 +100,7 @@ export class WsGateway {
|
|
|
81
100
|
private readonly uploads: AttachmentEndpoint;
|
|
82
101
|
private readonly images: ImageEndpoint;
|
|
83
102
|
private readonly catalogue: SessionCatalogue;
|
|
103
|
+
private readonly meta: SessionMeta;
|
|
84
104
|
private readonly client: StaticClient;
|
|
85
105
|
private readonly streams = new Map<string, SessionStream>();
|
|
86
106
|
private readonly opening = new Map<string, Promise<SessionStream>>();
|
|
@@ -89,6 +109,9 @@ export class WsGateway {
|
|
|
89
109
|
ClientConnection
|
|
90
110
|
>();
|
|
91
111
|
private readonly reloader: Reloader;
|
|
112
|
+
private readonly git = new GitMonitor();
|
|
113
|
+
private readonly pollMs: number | undefined;
|
|
114
|
+
private readonly busyRepos = new Set<string>();
|
|
92
115
|
private versionsRead: Promise<readonly [string, string]> | undefined;
|
|
93
116
|
private server: Server<undefined> | undefined;
|
|
94
117
|
|
|
@@ -96,15 +119,18 @@ export class WsGateway {
|
|
|
96
119
|
this.registry = deps.registry;
|
|
97
120
|
this.hostname = deps.hostname ?? DEFAULT_HOSTNAME;
|
|
98
121
|
this.requestedPort = deps.port ?? DEFAULT_PORT;
|
|
122
|
+
this.pollMs = deps.pollMs;
|
|
99
123
|
this.uploads = new AttachmentEndpoint(
|
|
100
124
|
deps.attachmentsRoot === undefined ? {} : { root: deps.attachmentsRoot }
|
|
101
125
|
);
|
|
102
126
|
this.images = new ImageEndpoint(
|
|
103
127
|
deps.imagesRoot === undefined ? {} : { root: deps.imagesRoot }
|
|
104
128
|
);
|
|
129
|
+
this.meta = new SessionMeta(deps.sessionMetaPath);
|
|
105
130
|
this.catalogue = new SessionCatalogue({
|
|
106
131
|
registry: deps.registry,
|
|
107
132
|
cursors: new ReadCursors(deps.readCursorsPath),
|
|
133
|
+
meta: this.meta,
|
|
108
134
|
liveStatus: (sessionId) => this.streams.get(sessionId)?.host.status,
|
|
109
135
|
liveSessionIds: () => this.streams.keys(),
|
|
110
136
|
isBeingRead: (sessionId) => this.isBeingRead(sessionId),
|
|
@@ -145,7 +171,9 @@ export class WsGateway {
|
|
|
145
171
|
fetch: (req, server) => {
|
|
146
172
|
const { pathname } = new URL(req.url);
|
|
147
173
|
if (pathname === "/health") {
|
|
148
|
-
return
|
|
174
|
+
return this.versions().then(([pimVersion]) =>
|
|
175
|
+
Response.json({ ok: true, pimVersion })
|
|
176
|
+
);
|
|
149
177
|
}
|
|
150
178
|
if (AttachmentEndpoint.owns(pathname)) {
|
|
151
179
|
return this.uploads.handle(req);
|
|
@@ -159,6 +187,8 @@ export class WsGateway {
|
|
|
159
187
|
websocket: {
|
|
160
188
|
backpressureLimit: 8 << 20,
|
|
161
189
|
closeOnBackpressureLimit: false,
|
|
190
|
+
// Dedicated, so the window survives between frames: the repeated event envelope is most of a delta.
|
|
191
|
+
perMessageDeflate: { compress: "dedicated", decompress: "dedicated" },
|
|
162
192
|
open: (ws) => {
|
|
163
193
|
this.connections.set(ws, new ClientConnection(ws));
|
|
164
194
|
this.syncWatches();
|
|
@@ -184,6 +214,8 @@ export class WsGateway {
|
|
|
184
214
|
}
|
|
185
215
|
this.streams.clear();
|
|
186
216
|
this.opening.clear();
|
|
217
|
+
this.git.dispose();
|
|
218
|
+
this.busyRepos.clear();
|
|
187
219
|
this.catalogue.watch(false);
|
|
188
220
|
this.catalogue.clear();
|
|
189
221
|
await this.catalogue.flush();
|
|
@@ -217,19 +249,6 @@ export class WsGateway {
|
|
|
217
249
|
connection.send({ type: "error", message: "frame is not a command" });
|
|
218
250
|
return;
|
|
219
251
|
}
|
|
220
|
-
if (
|
|
221
|
-
command.type === "attach" &&
|
|
222
|
-
command.protocolVersion !== PROTOCOL_VERSION
|
|
223
|
-
) {
|
|
224
|
-
connection.send({
|
|
225
|
-
type: "response",
|
|
226
|
-
id: command.id,
|
|
227
|
-
success: false,
|
|
228
|
-
error: `unsupported protocol version ${String(command.protocolVersion)}; this server speaks ${PROTOCOL_VERSION}`,
|
|
229
|
-
});
|
|
230
|
-
ws.close(CLOSE_PROTOCOL_MISMATCH, "protocol version mismatch");
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
252
|
try {
|
|
234
253
|
const { error, after, ...answer } = await this.dispatch(
|
|
235
254
|
connection,
|
|
@@ -260,8 +279,83 @@ export class WsGateway {
|
|
|
260
279
|
switch (command.type) {
|
|
261
280
|
case "attach":
|
|
262
281
|
return await this.attach(connection, command);
|
|
282
|
+
case "attention": {
|
|
283
|
+
const regained = command.value && !connection.attentive;
|
|
284
|
+
connection.setAttentive(command.value);
|
|
285
|
+
const sessionId = connection.sessionId;
|
|
286
|
+
if (regained && sessionId !== undefined) {
|
|
287
|
+
await this.catalogue.markRead(sessionId);
|
|
288
|
+
}
|
|
289
|
+
return {};
|
|
290
|
+
}
|
|
263
291
|
case "list_sessions":
|
|
264
|
-
return
|
|
292
|
+
return await this.catalogue.list(command);
|
|
293
|
+
case "search_sessions":
|
|
294
|
+
return await this.catalogue.search(command);
|
|
295
|
+
// The four below take a session this server may never have opened: a row
|
|
296
|
+
// is archived or renamed from the sidebar without being attached to, so
|
|
297
|
+
// none of them may reach for a stream. A sidecar write moves no session
|
|
298
|
+
// file and produces no `sessions_changed`, so the broadcast each ends
|
|
299
|
+
// with is the only word a listing already in a client's hands gets.
|
|
300
|
+
case "set_session_name": {
|
|
301
|
+
const name = await this.registry.setName(
|
|
302
|
+
command.sessionId,
|
|
303
|
+
command.value
|
|
304
|
+
);
|
|
305
|
+
this.broadcast({
|
|
306
|
+
type: "session_meta",
|
|
307
|
+
sessionId: command.sessionId,
|
|
308
|
+
name: name ?? null,
|
|
309
|
+
});
|
|
310
|
+
return {};
|
|
311
|
+
}
|
|
312
|
+
case "set_session_archived":
|
|
313
|
+
await this.meta.setArchived(command.sessionId, command.value);
|
|
314
|
+
this.broadcast({
|
|
315
|
+
type: "session_meta",
|
|
316
|
+
sessionId: command.sessionId,
|
|
317
|
+
archived: command.value,
|
|
318
|
+
});
|
|
319
|
+
return {};
|
|
320
|
+
case "set_session_unread":
|
|
321
|
+
await this.meta.setUnread(command.sessionId, command.value);
|
|
322
|
+
this.broadcast({
|
|
323
|
+
type: "session_meta",
|
|
324
|
+
sessionId: command.sessionId,
|
|
325
|
+
unread: command.value,
|
|
326
|
+
});
|
|
327
|
+
return {};
|
|
328
|
+
case "set_project_pinned":
|
|
329
|
+
await this.meta.setPinned(command.cwd, command.value);
|
|
330
|
+
this.broadcast({
|
|
331
|
+
type: "project_meta",
|
|
332
|
+
cwd: command.cwd,
|
|
333
|
+
pinned: command.value,
|
|
334
|
+
});
|
|
335
|
+
// The flag and the place it takes are one fact to a sidebar; a pin
|
|
336
|
+
// that arrived without a rank would sort by nothing until the next listing.
|
|
337
|
+
this.broadcast({ type: "pins_changed", order: await this.meta.pins() });
|
|
338
|
+
return {};
|
|
339
|
+
case "set_project_expanded":
|
|
340
|
+
await this.meta.setExpanded(command.cwd, command.value);
|
|
341
|
+
this.broadcast({
|
|
342
|
+
type: "project_meta",
|
|
343
|
+
cwd: command.cwd,
|
|
344
|
+
expanded: command.value,
|
|
345
|
+
});
|
|
346
|
+
return {};
|
|
347
|
+
case "set_project_label":
|
|
348
|
+
await this.meta.setLabel(command.cwd, command.value);
|
|
349
|
+
this.broadcast({
|
|
350
|
+
type: "project_meta",
|
|
351
|
+
cwd: command.cwd,
|
|
352
|
+
label: command.value,
|
|
353
|
+
});
|
|
354
|
+
return {};
|
|
355
|
+
case "set_pin_order":
|
|
356
|
+
await this.meta.setPinOrder(command.order);
|
|
357
|
+
this.broadcast({ type: "pins_changed", order: await this.meta.pins() });
|
|
358
|
+
return {};
|
|
265
359
|
case "list_models":
|
|
266
360
|
return {
|
|
267
361
|
models: this.registry.models(),
|
|
@@ -270,6 +364,61 @@ export class WsGateway {
|
|
|
270
364
|
};
|
|
271
365
|
case "list_dirs":
|
|
272
366
|
return { directory: await Directories.list(command.path) };
|
|
367
|
+
case "refresh_git": {
|
|
368
|
+
const stream = this.requireStream(connection);
|
|
369
|
+
await stream.refreshGit(command.fetch === true);
|
|
370
|
+
return {};
|
|
371
|
+
}
|
|
372
|
+
case "list_branches":
|
|
373
|
+
return {
|
|
374
|
+
branches: await Git.listBranches(
|
|
375
|
+
this.requireStream(connection).host.cwd
|
|
376
|
+
),
|
|
377
|
+
};
|
|
378
|
+
// Reading the repository never moves it, so neither of these takes the `repoBusy` refusal.
|
|
379
|
+
case "list_changes":
|
|
380
|
+
return {
|
|
381
|
+
changes: await RepoDiff.listChanges(
|
|
382
|
+
this.requireStream(connection).host.cwd,
|
|
383
|
+
command.base,
|
|
384
|
+
this.git
|
|
385
|
+
),
|
|
386
|
+
};
|
|
387
|
+
case "file_diff":
|
|
388
|
+
return {
|
|
389
|
+
fileDiff: await RepoDiff.fileDiff(
|
|
390
|
+
this.requireStream(connection).host.cwd,
|
|
391
|
+
command.base,
|
|
392
|
+
command.path,
|
|
393
|
+
this.git,
|
|
394
|
+
command.context
|
|
395
|
+
),
|
|
396
|
+
};
|
|
397
|
+
case "read_lines":
|
|
398
|
+
return {
|
|
399
|
+
fileLines: await RepoDiff.readLines(
|
|
400
|
+
this.requireStream(connection).host.cwd,
|
|
401
|
+
command.base,
|
|
402
|
+
command.path,
|
|
403
|
+
command.spans,
|
|
404
|
+
this.git
|
|
405
|
+
),
|
|
406
|
+
};
|
|
407
|
+
case "checkout": {
|
|
408
|
+
const stream = this.requireStream(connection);
|
|
409
|
+
return await this.runGit(stream, true, (cwd) =>
|
|
410
|
+
Git.checkout(cwd, command.branch)
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
case "commit":
|
|
414
|
+
return await this.commit(this.requireStream(connection), command);
|
|
415
|
+
case "pull":
|
|
416
|
+
case "push":
|
|
417
|
+
return await this.runGit(
|
|
418
|
+
this.requireStream(connection),
|
|
419
|
+
command.type === "pull",
|
|
420
|
+
command.type === "pull" ? Git.pull : Git.push
|
|
421
|
+
);
|
|
273
422
|
case "unwatch_subagent":
|
|
274
423
|
connection.unwatchSubagent(command.callId);
|
|
275
424
|
return {};
|
|
@@ -288,6 +437,7 @@ export class WsGateway {
|
|
|
288
437
|
case "set_cwd": {
|
|
289
438
|
const stream = this.requireStream(connection);
|
|
290
439
|
const result = await stream.host.setCwd(command.value);
|
|
440
|
+
stream.syncGit();
|
|
291
441
|
stream.push(stream.sessionState());
|
|
292
442
|
if (!result.ok) {
|
|
293
443
|
return { error: result.error };
|
|
@@ -365,7 +515,8 @@ export class WsGateway {
|
|
|
365
515
|
if (path === null) {
|
|
366
516
|
return { error: `malformed call id: ${command.callId}` };
|
|
367
517
|
}
|
|
368
|
-
|
|
518
|
+
// A running call may not have written its log yet; the watch waits for it.
|
|
519
|
+
if (!stream.isRunning(command.callId) && !(await Bun.file(path).exists())) {
|
|
369
520
|
return { error: `no subagent log for call ${command.callId}` };
|
|
370
521
|
}
|
|
371
522
|
await connection.watchSubagent(
|
|
@@ -380,6 +531,7 @@ export class WsGateway {
|
|
|
380
531
|
connection: ClientConnection,
|
|
381
532
|
command: Command & { readonly type: "attach" }
|
|
382
533
|
): Promise<Outcome> {
|
|
534
|
+
const attentive = command.attentive !== false;
|
|
383
535
|
const like =
|
|
384
536
|
command.like === undefined
|
|
385
537
|
? undefined
|
|
@@ -396,27 +548,98 @@ export class WsGateway {
|
|
|
396
548
|
]);
|
|
397
549
|
connection.send({
|
|
398
550
|
type: "attached",
|
|
399
|
-
protocolVersion: PROTOCOL_VERSION,
|
|
400
551
|
sessionId: stream.sessionId,
|
|
401
552
|
cwd: stream.host.cwd,
|
|
402
553
|
head: await stream.refresh(),
|
|
403
554
|
pimVersion,
|
|
404
555
|
piVersion,
|
|
405
556
|
});
|
|
406
|
-
await connection.attach(stream, command.fromSeq);
|
|
557
|
+
await connection.attach(stream, command.fromSeq, attentive);
|
|
407
558
|
this.syncWatches();
|
|
408
|
-
|
|
559
|
+
if (attentive) {
|
|
560
|
+
await this.catalogue.markRead(stream.sessionId);
|
|
561
|
+
}
|
|
409
562
|
return {};
|
|
410
563
|
}
|
|
411
564
|
|
|
412
|
-
/** A watch costs a poll, so only sessions
|
|
565
|
+
/** A watch costs a poll, so only attached sessions get one — and the catalogue only while someone is connected. */
|
|
413
566
|
private syncWatches(): void {
|
|
414
567
|
for (const [sessionId, stream] of this.streams) {
|
|
415
|
-
|
|
568
|
+
// Attachment, not attention: a hidden tab that lost its watches would stop hearing about the session.
|
|
569
|
+
stream.watchFiles(this.isAttached(sessionId));
|
|
416
570
|
}
|
|
417
571
|
this.catalogue.watch(this.connections.size > 0);
|
|
418
572
|
}
|
|
419
573
|
|
|
574
|
+
/**
|
|
575
|
+
* An operation that moves the working tree cannot run under an agent that
|
|
576
|
+
* may be halfway through an edit; `push` leaves the tree alone, so it can.
|
|
577
|
+
*/
|
|
578
|
+
private async runGit<T = never>(
|
|
579
|
+
stream: SessionStream,
|
|
580
|
+
movesTree: boolean,
|
|
581
|
+
operation: (cwd: string) => Promise<GitRun<T>>
|
|
582
|
+
): Promise<Outcome & { readonly value?: T }> {
|
|
583
|
+
const cwd = stream.host.cwd;
|
|
584
|
+
if (movesTree && this.repoBusy(cwd)) {
|
|
585
|
+
return {
|
|
586
|
+
error: `an agent is working in ${cwd}; wait for its turn to end`,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
const result = await this.git.run<T>(cwd, () => operation(cwd));
|
|
590
|
+
if (!result.ok) {
|
|
591
|
+
return { error: result.error };
|
|
592
|
+
}
|
|
593
|
+
return result.value === undefined ? {} : { value: result.value };
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private async commit(
|
|
597
|
+
stream: SessionStream,
|
|
598
|
+
command: Command & { readonly type: "commit" }
|
|
599
|
+
): Promise<Outcome> {
|
|
600
|
+
const { value, ...outcome } = await this.runGit<string>(
|
|
601
|
+
stream,
|
|
602
|
+
true,
|
|
603
|
+
async (cwd) => {
|
|
604
|
+
const result = await Git.commit(cwd, {
|
|
605
|
+
message: command.message,
|
|
606
|
+
paths: command.paths,
|
|
607
|
+
});
|
|
608
|
+
return result.ok
|
|
609
|
+
? { ok: true, value: result.sha }
|
|
610
|
+
: { ok: false, error: result.error };
|
|
611
|
+
}
|
|
612
|
+
);
|
|
613
|
+
return value === undefined ? outcome : { commit: { sha: value } };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private repoBusy(cwd: string): boolean {
|
|
617
|
+
for (const stream of this.streams.values()) {
|
|
618
|
+
if (stream.host.cwd === cwd && stream.host.status !== "idle") {
|
|
619
|
+
return true;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return false;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** A turn starting anywhere freezes the branch menu of every session beside it, which only their own state can say. */
|
|
626
|
+
private syncRepoBusy(cwd: string, source: string): void {
|
|
627
|
+
const busy = this.repoBusy(cwd);
|
|
628
|
+
if (this.busyRepos.has(cwd) === busy) {
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
if (busy) {
|
|
632
|
+
this.busyRepos.add(cwd);
|
|
633
|
+
} else {
|
|
634
|
+
this.busyRepos.delete(cwd);
|
|
635
|
+
}
|
|
636
|
+
for (const [sessionId, stream] of this.streams) {
|
|
637
|
+
if (sessionId !== source && stream.host.cwd === cwd) {
|
|
638
|
+
stream.push(stream.sessionState());
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
420
643
|
private reload(force: boolean): Outcome {
|
|
421
644
|
const busy = [...this.streams.values()]
|
|
422
645
|
.filter((stream) => stream.host.status !== "idle")
|
|
@@ -439,13 +662,20 @@ export class WsGateway {
|
|
|
439
662
|
return this.versionsRead;
|
|
440
663
|
}
|
|
441
664
|
|
|
665
|
+
private isAttached(sessionId: string): boolean {
|
|
666
|
+
return this.connections
|
|
667
|
+
.values()
|
|
668
|
+
.some((connection) => connection.sessionId === sessionId);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// The TUI never attaches, so reading a session there clears no dot here and a turn it runs trips no mark.
|
|
442
672
|
private isBeingRead(sessionId: string): boolean {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
673
|
+
return this.connections
|
|
674
|
+
.values()
|
|
675
|
+
.some(
|
|
676
|
+
(connection) =>
|
|
677
|
+
connection.sessionId === sessionId && connection.attentive
|
|
678
|
+
);
|
|
449
679
|
}
|
|
450
680
|
|
|
451
681
|
private broadcast(event: ServerEvent): void {
|
|
@@ -498,12 +728,17 @@ export class WsGateway {
|
|
|
498
728
|
if (existing) {
|
|
499
729
|
return existing;
|
|
500
730
|
}
|
|
501
|
-
const stream = new SessionStream(id, host, path
|
|
731
|
+
const stream = new SessionStream(id, host, path, {
|
|
732
|
+
git: this.git,
|
|
733
|
+
repoBusy: () => this.repoBusy(host.cwd),
|
|
734
|
+
pollMs: this.pollMs,
|
|
735
|
+
});
|
|
502
736
|
stream.start();
|
|
503
737
|
this.catalogue.track(id, host.status);
|
|
504
738
|
stream.subscribe((event) => {
|
|
505
739
|
if (event.type === "session_state") {
|
|
506
740
|
this.catalogue.onStatus(id, event.status);
|
|
741
|
+
this.syncRepoBusy(stream.host.cwd, id);
|
|
507
742
|
}
|
|
508
743
|
});
|
|
509
744
|
this.streams.set(id, stream);
|