pim-agent 0.9.0 → 0.11.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 -3
- 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/bash/index.ts +2 -1
- package/packages/core/src/extensions/bash/run.ts +8 -2
- package/packages/core/src/extensions/system-prompt/index.ts +45 -22
- package/packages/core/src/extensions/system-prompt/prompt.ts +6 -3
- 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 +52 -10
- 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 +111 -15
- package/packages/core/src/session/SessionUi.ts +127 -0
- 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/Directories.ts +39 -5
- package/packages/core/src/shared/ExtensionToggles.ts +8 -0
- 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/PiExtensions.ts +228 -0
- 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 +151 -3
- package/packages/protocol/src/Diff.ts +15 -0
- package/packages/protocol/src/ServerEvent.ts +191 -2
- package/packages/server/src/ClientConnection.ts +15 -3
- package/packages/server/src/ProbeClient.ts +111 -28
- package/packages/server/src/SessionCatalogue.ts +292 -38
- package/packages/server/src/SessionStream.ts +297 -45
- package/packages/server/src/StaticClient.ts +95 -12
- package/packages/server/src/WsGateway.ts +417 -45
- package/packages/server/src/probe.ts +77 -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/CommitMonoV143-VF-ytizKI8U.woff2 +0 -0
- package/packages/web/dist/client/assets/{core-CGdQx9la.js → core-D3tyj0PP.js} +1 -1
- package/packages/web/dist/client/assets/index-CTrlTM53.js +45 -0
- package/packages/web/dist/client/assets/index-JnduS4Cd.css +1 -0
- package/packages/web/dist/client/index.html +2 -2
- package/packages/protocol/src/Protocol.ts +0 -6
- 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/index-BMNwleWO.js +0 -40
- package/packages/web/dist/client/assets/index-CqjB2RPr.css +0 -1
|
@@ -7,16 +7,27 @@ 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 {
|
|
13
|
+
PiExtensions,
|
|
14
|
+
type ExtensionEntry,
|
|
15
|
+
type ExtensionScope,
|
|
16
|
+
} from "#core/shared/PiExtensions";
|
|
17
|
+
import { RepoDiff } from "#core/shared/RepoDiff";
|
|
10
18
|
import { ReadCursors } from "#core/session/ReadCursors";
|
|
11
19
|
import type { SessionHost } from "#core/session/SessionHost";
|
|
20
|
+
import { SessionMeta } from "#core/session/SessionMeta";
|
|
12
21
|
import type { SessionRegistry } from "#core/session/SessionRegistry";
|
|
13
22
|
import { PimVersion } from "#core/shared/PimVersion";
|
|
14
23
|
import { SubagentLogs } from "#core/shared/SubagentLogs";
|
|
15
24
|
import type { UpdateOutcome } from "#core/shared/Updater";
|
|
16
25
|
import type { Command } from "#protocol/Command";
|
|
17
|
-
import {
|
|
26
|
+
import type { ChangeList, FileDiff, FileLines } from "#protocol/Diff";
|
|
18
27
|
import type {
|
|
19
28
|
ModelView,
|
|
29
|
+
ProjectView,
|
|
30
|
+
SearchHitView,
|
|
20
31
|
ServerEvent,
|
|
21
32
|
SessionSummaryView,
|
|
22
33
|
} from "#protocol/ServerEvent";
|
|
@@ -41,6 +52,14 @@ export type WsGatewayDeps = {
|
|
|
41
52
|
readonly imagesRoot?: string;
|
|
42
53
|
/** Where the read cursors live; defaults to `~/.pim/read.json`. */
|
|
43
54
|
readonly readCursorsPath?: string;
|
|
55
|
+
/** Where the per-session and per-project overrides live; defaults to `~/.pim/sessions.json`. */
|
|
56
|
+
readonly sessionMetaPath?: string;
|
|
57
|
+
/** How often a session file is polled where `fs.watch` says nothing; the default is a second. */
|
|
58
|
+
readonly pollMs?: number;
|
|
59
|
+
/** Longest a dialog may hold an extension waiting; the default is `SessionStream`'s three minutes. */
|
|
60
|
+
readonly requestCeilingMs?: number;
|
|
61
|
+
/** How long a pending dialog outlives its last reader; the default is `SessionStream`'s fifteen seconds. */
|
|
62
|
+
readonly detachGraceMs?: number;
|
|
44
63
|
/** The built web client; defaults to the bundle shipped beside this package. */
|
|
45
64
|
readonly clientDir?: string;
|
|
46
65
|
/** Runs the update a `reload` asks for, reporting each step as it starts. */
|
|
@@ -53,10 +72,22 @@ type Outcome = {
|
|
|
53
72
|
readonly error?: string;
|
|
54
73
|
readonly items?: readonly PickerItem[];
|
|
55
74
|
readonly sessions?: readonly SessionSummaryView[];
|
|
75
|
+
readonly projects?: readonly ProjectView[];
|
|
76
|
+
readonly hits?: readonly SearchHitView[];
|
|
77
|
+
readonly dropped?: readonly string[];
|
|
78
|
+
readonly scanned?: number;
|
|
56
79
|
readonly models?: readonly ModelView[];
|
|
57
80
|
readonly thinkingLevels?: readonly string[];
|
|
81
|
+
readonly extensions?: readonly ExtensionEntry[];
|
|
58
82
|
readonly directory?: DirectoryListing;
|
|
83
|
+
readonly branches?: readonly GitBranch[];
|
|
84
|
+
readonly commit?: { readonly sha: string };
|
|
85
|
+
readonly changes?: ChangeList;
|
|
86
|
+
readonly fileDiff?: FileDiff;
|
|
87
|
+
readonly fileLines?: FileLines;
|
|
59
88
|
readonly restored?: readonly string[];
|
|
89
|
+
/** The message was dispatched as an extension command: no turn started, no entry written. */
|
|
90
|
+
readonly dispatched?: boolean;
|
|
60
91
|
readonly after?: () => void;
|
|
61
92
|
};
|
|
62
93
|
|
|
@@ -73,6 +104,15 @@ export const DEFAULT_HOSTNAME = "127.0.0.1";
|
|
|
73
104
|
// Bun 1.3.14 never frees the slot of a server-closed socket, so an unbounded `Server.stop(true)` hangs.
|
|
74
105
|
const STOP_GRACE_MS = 250;
|
|
75
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Pi's own dispatch parse, three lines of `_tryExecuteExtensionCommand`
|
|
109
|
+
* (`agent-session.js:954`); a pi upgrade re-verifies it.
|
|
110
|
+
*/
|
|
111
|
+
function commandNameOf(text: string): string {
|
|
112
|
+
const spaceIndex = text.indexOf(" ");
|
|
113
|
+
return spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
|
|
114
|
+
}
|
|
115
|
+
|
|
76
116
|
/** The transport half of pim-server: a WebSocket endpoint over the session runtime in `core`. */
|
|
77
117
|
export class WsGateway {
|
|
78
118
|
private readonly registry: SessionRegistry;
|
|
@@ -81,6 +121,7 @@ export class WsGateway {
|
|
|
81
121
|
private readonly uploads: AttachmentEndpoint;
|
|
82
122
|
private readonly images: ImageEndpoint;
|
|
83
123
|
private readonly catalogue: SessionCatalogue;
|
|
124
|
+
private readonly meta: SessionMeta;
|
|
84
125
|
private readonly client: StaticClient;
|
|
85
126
|
private readonly streams = new Map<string, SessionStream>();
|
|
86
127
|
private readonly opening = new Map<string, Promise<SessionStream>>();
|
|
@@ -89,22 +130,39 @@ export class WsGateway {
|
|
|
89
130
|
ClientConnection
|
|
90
131
|
>();
|
|
91
132
|
private readonly reloader: Reloader;
|
|
133
|
+
private readonly git = new GitMonitor();
|
|
134
|
+
private readonly pollMs: number | undefined;
|
|
135
|
+
private readonly requestCeilingMs: number | undefined;
|
|
136
|
+
private readonly detachGraceMs: number | undefined;
|
|
137
|
+
private readonly busyRepos = new Set<string>();
|
|
92
138
|
private versionsRead: Promise<readonly [string, string]> | undefined;
|
|
93
139
|
private server: Server<undefined> | undefined;
|
|
94
140
|
|
|
95
141
|
public constructor(deps: WsGatewayDeps) {
|
|
96
142
|
this.registry = deps.registry;
|
|
143
|
+
// `SessionStream` is the sink, so the whole routing is a lookup: a host
|
|
144
|
+
// outlives the stream that speaks for it, and pi asks per call.
|
|
145
|
+
this.registry.setUi((host) =>
|
|
146
|
+
host.sessionId === undefined
|
|
147
|
+
? undefined
|
|
148
|
+
: this.streams.get(host.sessionId)
|
|
149
|
+
);
|
|
97
150
|
this.hostname = deps.hostname ?? DEFAULT_HOSTNAME;
|
|
98
151
|
this.requestedPort = deps.port ?? DEFAULT_PORT;
|
|
152
|
+
this.pollMs = deps.pollMs;
|
|
153
|
+
this.requestCeilingMs = deps.requestCeilingMs;
|
|
154
|
+
this.detachGraceMs = deps.detachGraceMs;
|
|
99
155
|
this.uploads = new AttachmentEndpoint(
|
|
100
156
|
deps.attachmentsRoot === undefined ? {} : { root: deps.attachmentsRoot }
|
|
101
157
|
);
|
|
102
158
|
this.images = new ImageEndpoint(
|
|
103
159
|
deps.imagesRoot === undefined ? {} : { root: deps.imagesRoot }
|
|
104
160
|
);
|
|
161
|
+
this.meta = new SessionMeta(deps.sessionMetaPath);
|
|
105
162
|
this.catalogue = new SessionCatalogue({
|
|
106
163
|
registry: deps.registry,
|
|
107
164
|
cursors: new ReadCursors(deps.readCursorsPath),
|
|
165
|
+
meta: this.meta,
|
|
108
166
|
liveStatus: (sessionId) => this.streams.get(sessionId)?.host.status,
|
|
109
167
|
liveSessionIds: () => this.streams.keys(),
|
|
110
168
|
isBeingRead: (sessionId) => this.isBeingRead(sessionId),
|
|
@@ -145,7 +203,9 @@ export class WsGateway {
|
|
|
145
203
|
fetch: (req, server) => {
|
|
146
204
|
const { pathname } = new URL(req.url);
|
|
147
205
|
if (pathname === "/health") {
|
|
148
|
-
return
|
|
206
|
+
return this.versions().then(([pimVersion]) =>
|
|
207
|
+
Response.json({ ok: true, pimVersion })
|
|
208
|
+
);
|
|
149
209
|
}
|
|
150
210
|
if (AttachmentEndpoint.owns(pathname)) {
|
|
151
211
|
return this.uploads.handle(req);
|
|
@@ -159,6 +219,8 @@ export class WsGateway {
|
|
|
159
219
|
websocket: {
|
|
160
220
|
backpressureLimit: 8 << 20,
|
|
161
221
|
closeOnBackpressureLimit: false,
|
|
222
|
+
// Dedicated, so the window survives between frames: the repeated event envelope is most of a delta.
|
|
223
|
+
perMessageDeflate: { compress: "dedicated", decompress: "dedicated" },
|
|
162
224
|
open: (ws) => {
|
|
163
225
|
this.connections.set(ws, new ClientConnection(ws));
|
|
164
226
|
this.syncWatches();
|
|
@@ -184,6 +246,8 @@ export class WsGateway {
|
|
|
184
246
|
}
|
|
185
247
|
this.streams.clear();
|
|
186
248
|
this.opening.clear();
|
|
249
|
+
this.git.dispose();
|
|
250
|
+
this.busyRepos.clear();
|
|
187
251
|
this.catalogue.watch(false);
|
|
188
252
|
this.catalogue.clear();
|
|
189
253
|
await this.catalogue.flush();
|
|
@@ -217,19 +281,6 @@ export class WsGateway {
|
|
|
217
281
|
connection.send({ type: "error", message: "frame is not a command" });
|
|
218
282
|
return;
|
|
219
283
|
}
|
|
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
284
|
try {
|
|
234
285
|
const { error, after, ...answer } = await this.dispatch(
|
|
235
286
|
connection,
|
|
@@ -260,24 +311,180 @@ export class WsGateway {
|
|
|
260
311
|
switch (command.type) {
|
|
261
312
|
case "attach":
|
|
262
313
|
return await this.attach(connection, command);
|
|
314
|
+
case "attention": {
|
|
315
|
+
const regained = command.value && !connection.attentive;
|
|
316
|
+
connection.setAttentive(command.value);
|
|
317
|
+
const sessionId = connection.sessionId;
|
|
318
|
+
if (regained && sessionId !== undefined) {
|
|
319
|
+
await this.catalogue.markRead(sessionId);
|
|
320
|
+
}
|
|
321
|
+
return {};
|
|
322
|
+
}
|
|
263
323
|
case "list_sessions":
|
|
264
|
-
return
|
|
324
|
+
return await this.catalogue.list(command);
|
|
325
|
+
case "search_sessions":
|
|
326
|
+
return await this.catalogue.search(command);
|
|
327
|
+
// The four below take a session this server may never have opened: a row
|
|
328
|
+
// is archived or renamed from the sidebar without being attached to, so
|
|
329
|
+
// none of them may reach for a stream. A sidecar write moves no session
|
|
330
|
+
// file and produces no `sessions_changed`, so the broadcast each ends
|
|
331
|
+
// with is the only word a listing already in a client's hands gets.
|
|
332
|
+
case "set_session_name": {
|
|
333
|
+
const name = await this.registry.setName(
|
|
334
|
+
command.sessionId,
|
|
335
|
+
command.value
|
|
336
|
+
);
|
|
337
|
+
this.broadcast({
|
|
338
|
+
type: "session_meta",
|
|
339
|
+
sessionId: command.sessionId,
|
|
340
|
+
name: name ?? null,
|
|
341
|
+
});
|
|
342
|
+
return {};
|
|
343
|
+
}
|
|
344
|
+
case "set_session_archived":
|
|
345
|
+
await this.meta.setArchived(command.sessionId, command.value);
|
|
346
|
+
this.broadcast({
|
|
347
|
+
type: "session_meta",
|
|
348
|
+
sessionId: command.sessionId,
|
|
349
|
+
archived: command.value,
|
|
350
|
+
});
|
|
351
|
+
return {};
|
|
352
|
+
case "set_session_unread":
|
|
353
|
+
await this.meta.setUnread(command.sessionId, command.value);
|
|
354
|
+
this.broadcast({
|
|
355
|
+
type: "session_meta",
|
|
356
|
+
sessionId: command.sessionId,
|
|
357
|
+
unread: command.value,
|
|
358
|
+
});
|
|
359
|
+
return {};
|
|
360
|
+
case "set_project_pinned":
|
|
361
|
+
await this.meta.setPinned(command.cwd, command.value);
|
|
362
|
+
this.broadcast({
|
|
363
|
+
type: "project_meta",
|
|
364
|
+
cwd: command.cwd,
|
|
365
|
+
pinned: command.value,
|
|
366
|
+
});
|
|
367
|
+
// The flag and the place it takes are one fact to a sidebar; a pin
|
|
368
|
+
// that arrived without a rank would sort by nothing until the next listing.
|
|
369
|
+
this.broadcast({ type: "pins_changed", order: await this.meta.pins() });
|
|
370
|
+
return {};
|
|
371
|
+
case "set_project_expanded":
|
|
372
|
+
await this.meta.setExpanded(command.cwd, command.value);
|
|
373
|
+
this.broadcast({
|
|
374
|
+
type: "project_meta",
|
|
375
|
+
cwd: command.cwd,
|
|
376
|
+
expanded: command.value,
|
|
377
|
+
});
|
|
378
|
+
return {};
|
|
379
|
+
case "set_project_label":
|
|
380
|
+
await this.meta.setLabel(command.cwd, command.value);
|
|
381
|
+
this.broadcast({
|
|
382
|
+
type: "project_meta",
|
|
383
|
+
cwd: command.cwd,
|
|
384
|
+
label: command.value,
|
|
385
|
+
});
|
|
386
|
+
return {};
|
|
387
|
+
case "set_pin_order":
|
|
388
|
+
await this.meta.setPinOrder(command.order);
|
|
389
|
+
this.broadcast({ type: "pins_changed", order: await this.meta.pins() });
|
|
390
|
+
return {};
|
|
265
391
|
case "list_models":
|
|
266
392
|
return {
|
|
267
393
|
models: this.registry.models(),
|
|
268
394
|
thinkingLevels:
|
|
269
395
|
this.streamFor(connection)?.host.supportedThinkingLevels ?? [],
|
|
270
396
|
};
|
|
397
|
+
case "list_extensions":
|
|
398
|
+
return {
|
|
399
|
+
extensions: await PiExtensions.list(this.scopeFor(connection)),
|
|
400
|
+
};
|
|
401
|
+
case "set_extension":
|
|
402
|
+
await PiExtensions.setEnabled(
|
|
403
|
+
command.extensionId,
|
|
404
|
+
command.value,
|
|
405
|
+
this.scopeFor(connection)
|
|
406
|
+
);
|
|
407
|
+
return {
|
|
408
|
+
after: () => {
|
|
409
|
+
this.rebuildAgents();
|
|
410
|
+
},
|
|
411
|
+
};
|
|
271
412
|
case "list_dirs":
|
|
272
413
|
return { directory: await Directories.list(command.path) };
|
|
414
|
+
case "create_dir":
|
|
415
|
+
await Directories.create(command.path);
|
|
416
|
+
return {};
|
|
417
|
+
case "refresh_git": {
|
|
418
|
+
const stream = this.requireStream(connection);
|
|
419
|
+
await stream.refreshGit(command.fetch === true);
|
|
420
|
+
return {};
|
|
421
|
+
}
|
|
422
|
+
case "list_branches":
|
|
423
|
+
return {
|
|
424
|
+
branches: await Git.listBranches(
|
|
425
|
+
this.requireStream(connection).host.cwd
|
|
426
|
+
),
|
|
427
|
+
};
|
|
428
|
+
// Reading the repository never moves it, so neither of these takes the `repoBusy` refusal.
|
|
429
|
+
case "list_changes":
|
|
430
|
+
return {
|
|
431
|
+
changes: await RepoDiff.listChanges(
|
|
432
|
+
this.requireStream(connection).host.cwd,
|
|
433
|
+
command.base,
|
|
434
|
+
this.git
|
|
435
|
+
),
|
|
436
|
+
};
|
|
437
|
+
case "file_diff":
|
|
438
|
+
return {
|
|
439
|
+
fileDiff: await RepoDiff.fileDiff(
|
|
440
|
+
this.requireStream(connection).host.cwd,
|
|
441
|
+
command.base,
|
|
442
|
+
command.path,
|
|
443
|
+
this.git,
|
|
444
|
+
command.context
|
|
445
|
+
),
|
|
446
|
+
};
|
|
447
|
+
case "read_lines":
|
|
448
|
+
return {
|
|
449
|
+
fileLines: await RepoDiff.readLines(
|
|
450
|
+
this.requireStream(connection).host.cwd,
|
|
451
|
+
command.base,
|
|
452
|
+
command.path,
|
|
453
|
+
command.spans,
|
|
454
|
+
this.git
|
|
455
|
+
),
|
|
456
|
+
};
|
|
457
|
+
case "checkout": {
|
|
458
|
+
const stream = this.requireStream(connection);
|
|
459
|
+
return await this.runGit(stream, true, (cwd) =>
|
|
460
|
+
Git.checkout(cwd, command.branch)
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
case "commit":
|
|
464
|
+
return await this.commit(this.requireStream(connection), command);
|
|
465
|
+
case "pull":
|
|
466
|
+
case "push":
|
|
467
|
+
return await this.runGit(
|
|
468
|
+
this.requireStream(connection),
|
|
469
|
+
command.type === "pull",
|
|
470
|
+
command.type === "pull" ? Git.pull : Git.push
|
|
471
|
+
);
|
|
273
472
|
case "unwatch_subagent":
|
|
274
473
|
connection.unwatchSubagent(command.callId);
|
|
275
474
|
return {};
|
|
276
475
|
case "reload":
|
|
277
476
|
return this.reload(command.force === true);
|
|
278
477
|
case "user_message":
|
|
279
|
-
this.promptWithAttachments(
|
|
280
|
-
|
|
478
|
+
return await this.promptWithAttachments(
|
|
479
|
+
this.requireStream(connection),
|
|
480
|
+
command
|
|
481
|
+
);
|
|
482
|
+
case "ui_response": {
|
|
483
|
+
const stream = this.requireStream(connection);
|
|
484
|
+
return stream.answer(command.requestId, command)
|
|
485
|
+
? {}
|
|
486
|
+
: { error: `no request ${command.requestId} is waiting` };
|
|
487
|
+
}
|
|
281
488
|
case "cancel": {
|
|
282
489
|
const { cancelled, restored } =
|
|
283
490
|
await this.requireStream(connection).host.cancel();
|
|
@@ -288,6 +495,7 @@ export class WsGateway {
|
|
|
288
495
|
case "set_cwd": {
|
|
289
496
|
const stream = this.requireStream(connection);
|
|
290
497
|
const result = await stream.host.setCwd(command.value);
|
|
498
|
+
stream.syncGit();
|
|
291
499
|
stream.push(stream.sessionState());
|
|
292
500
|
if (!result.ok) {
|
|
293
501
|
return { error: result.error };
|
|
@@ -342,6 +550,27 @@ export class WsGateway {
|
|
|
342
550
|
return sessionId ? this.streams.get(sessionId) : undefined;
|
|
343
551
|
}
|
|
344
552
|
|
|
553
|
+
/** A session's directory decides which project extensions are listed; a connection without one reads this process's. */
|
|
554
|
+
private scopeFor(connection: ClientConnection): ExtensionScope {
|
|
555
|
+
return {
|
|
556
|
+
cwd: this.streamFor(connection)?.host.cwd ?? process.cwd(),
|
|
557
|
+
agentDir: this.registry.agentDir,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private rebuildAgents(): void {
|
|
562
|
+
for (const stream of this.streams.values()) {
|
|
563
|
+
const { host } = stream;
|
|
564
|
+
// Through the turn queue: detaching the agent under a running turn kills it.
|
|
565
|
+
void host
|
|
566
|
+
.serialize(() => host.invalidate())
|
|
567
|
+
.catch((err: unknown) => {
|
|
568
|
+
this.failed(stream, err, "extension reload");
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
this.broadcast({ type: "extensions_changed" });
|
|
572
|
+
}
|
|
573
|
+
|
|
345
574
|
private requireStream(connection: ClientConnection): SessionStream {
|
|
346
575
|
const stream = this.streamFor(connection);
|
|
347
576
|
if (!stream) {
|
|
@@ -365,7 +594,8 @@ export class WsGateway {
|
|
|
365
594
|
if (path === null) {
|
|
366
595
|
return { error: `malformed call id: ${command.callId}` };
|
|
367
596
|
}
|
|
368
|
-
|
|
597
|
+
// A running call may not have written its log yet; the watch waits for it.
|
|
598
|
+
if (!stream.isRunning(command.callId) && !(await Bun.file(path).exists())) {
|
|
369
599
|
return { error: `no subagent log for call ${command.callId}` };
|
|
370
600
|
}
|
|
371
601
|
await connection.watchSubagent(
|
|
@@ -380,6 +610,7 @@ export class WsGateway {
|
|
|
380
610
|
connection: ClientConnection,
|
|
381
611
|
command: Command & { readonly type: "attach" }
|
|
382
612
|
): Promise<Outcome> {
|
|
613
|
+
const attentive = command.attentive !== false;
|
|
383
614
|
const like =
|
|
384
615
|
command.like === undefined
|
|
385
616
|
? undefined
|
|
@@ -396,27 +627,98 @@ export class WsGateway {
|
|
|
396
627
|
]);
|
|
397
628
|
connection.send({
|
|
398
629
|
type: "attached",
|
|
399
|
-
protocolVersion: PROTOCOL_VERSION,
|
|
400
630
|
sessionId: stream.sessionId,
|
|
401
631
|
cwd: stream.host.cwd,
|
|
402
632
|
head: await stream.refresh(),
|
|
403
633
|
pimVersion,
|
|
404
634
|
piVersion,
|
|
405
635
|
});
|
|
406
|
-
await connection.attach(stream, command.fromSeq);
|
|
636
|
+
await connection.attach(stream, command.fromSeq, attentive);
|
|
407
637
|
this.syncWatches();
|
|
408
|
-
|
|
638
|
+
if (attentive) {
|
|
639
|
+
await this.catalogue.markRead(stream.sessionId);
|
|
640
|
+
}
|
|
409
641
|
return {};
|
|
410
642
|
}
|
|
411
643
|
|
|
412
|
-
/** A watch costs a poll, so only sessions
|
|
644
|
+
/** A watch costs a poll, so only attached sessions get one — and the catalogue only while someone is connected. */
|
|
413
645
|
private syncWatches(): void {
|
|
414
646
|
for (const [sessionId, stream] of this.streams) {
|
|
415
|
-
|
|
647
|
+
// Attachment, not attention: a hidden tab that lost its watches would stop hearing about the session.
|
|
648
|
+
stream.watchFiles(this.isAttached(sessionId));
|
|
416
649
|
}
|
|
417
650
|
this.catalogue.watch(this.connections.size > 0);
|
|
418
651
|
}
|
|
419
652
|
|
|
653
|
+
/**
|
|
654
|
+
* An operation that moves the working tree cannot run under an agent that
|
|
655
|
+
* may be halfway through an edit; `push` leaves the tree alone, so it can.
|
|
656
|
+
*/
|
|
657
|
+
private async runGit<T = never>(
|
|
658
|
+
stream: SessionStream,
|
|
659
|
+
movesTree: boolean,
|
|
660
|
+
operation: (cwd: string) => Promise<GitRun<T>>
|
|
661
|
+
): Promise<Outcome & { readonly value?: T }> {
|
|
662
|
+
const cwd = stream.host.cwd;
|
|
663
|
+
if (movesTree && this.repoBusy(cwd)) {
|
|
664
|
+
return {
|
|
665
|
+
error: `an agent is working in ${cwd}; wait for its turn to end`,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
const result = await this.git.run<T>(cwd, () => operation(cwd));
|
|
669
|
+
if (!result.ok) {
|
|
670
|
+
return { error: result.error };
|
|
671
|
+
}
|
|
672
|
+
return result.value === undefined ? {} : { value: result.value };
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
private async commit(
|
|
676
|
+
stream: SessionStream,
|
|
677
|
+
command: Command & { readonly type: "commit" }
|
|
678
|
+
): Promise<Outcome> {
|
|
679
|
+
const { value, ...outcome } = await this.runGit<string>(
|
|
680
|
+
stream,
|
|
681
|
+
true,
|
|
682
|
+
async (cwd) => {
|
|
683
|
+
const result = await Git.commit(cwd, {
|
|
684
|
+
message: command.message,
|
|
685
|
+
paths: command.paths,
|
|
686
|
+
});
|
|
687
|
+
return result.ok
|
|
688
|
+
? { ok: true, value: result.sha }
|
|
689
|
+
: { ok: false, error: result.error };
|
|
690
|
+
}
|
|
691
|
+
);
|
|
692
|
+
return value === undefined ? outcome : { commit: { sha: value } };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
private repoBusy(cwd: string): boolean {
|
|
696
|
+
for (const stream of this.streams.values()) {
|
|
697
|
+
if (stream.host.cwd === cwd && stream.host.status !== "idle") {
|
|
698
|
+
return true;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return false;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** A turn starting anywhere freezes the branch menu of every session beside it, which only their own state can say. */
|
|
705
|
+
private syncRepoBusy(cwd: string, source: string): void {
|
|
706
|
+
const busy = this.repoBusy(cwd);
|
|
707
|
+
if (this.busyRepos.has(cwd) === busy) {
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
if (busy) {
|
|
711
|
+
this.busyRepos.add(cwd);
|
|
712
|
+
} else {
|
|
713
|
+
this.busyRepos.delete(cwd);
|
|
714
|
+
}
|
|
715
|
+
for (const [sessionId, stream] of this.streams) {
|
|
716
|
+
if (sessionId !== source && stream.host.cwd === cwd) {
|
|
717
|
+
stream.push(stream.sessionState());
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
420
722
|
private reload(force: boolean): Outcome {
|
|
421
723
|
const busy = [...this.streams.values()]
|
|
422
724
|
.filter((stream) => stream.host.status !== "idle")
|
|
@@ -439,13 +741,20 @@ export class WsGateway {
|
|
|
439
741
|
return this.versionsRead;
|
|
440
742
|
}
|
|
441
743
|
|
|
744
|
+
private isAttached(sessionId: string): boolean {
|
|
745
|
+
return this.connections
|
|
746
|
+
.values()
|
|
747
|
+
.some((connection) => connection.sessionId === sessionId);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// The TUI never attaches, so reading a session there clears no dot here and a turn it runs trips no mark.
|
|
442
751
|
private isBeingRead(sessionId: string): boolean {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
752
|
+
return this.connections
|
|
753
|
+
.values()
|
|
754
|
+
.some(
|
|
755
|
+
(connection) =>
|
|
756
|
+
connection.sessionId === sessionId && connection.attentive
|
|
757
|
+
);
|
|
449
758
|
}
|
|
450
759
|
|
|
451
760
|
private broadcast(event: ServerEvent): void {
|
|
@@ -498,29 +807,91 @@ export class WsGateway {
|
|
|
498
807
|
if (existing) {
|
|
499
808
|
return existing;
|
|
500
809
|
}
|
|
501
|
-
const stream = new SessionStream(id, host, path
|
|
810
|
+
const stream = new SessionStream(id, host, path, {
|
|
811
|
+
git: this.git,
|
|
812
|
+
repoBusy: () => this.repoBusy(host.cwd),
|
|
813
|
+
pollMs: this.pollMs,
|
|
814
|
+
requestCeilingMs: this.requestCeilingMs,
|
|
815
|
+
detachGraceMs: this.detachGraceMs,
|
|
816
|
+
});
|
|
502
817
|
stream.start();
|
|
503
818
|
this.catalogue.track(id, host.status);
|
|
504
|
-
|
|
819
|
+
// Observed rather than subscribed: this never leaves, and a reader that
|
|
820
|
+
// never leaves is a session that is never detached.
|
|
821
|
+
stream.observe((event) => {
|
|
505
822
|
if (event.type === "session_state") {
|
|
506
823
|
this.catalogue.onStatus(id, event.status);
|
|
824
|
+
this.syncRepoBusy(stream.host.cwd, id);
|
|
507
825
|
}
|
|
508
826
|
});
|
|
509
827
|
this.streams.set(id, stream);
|
|
510
828
|
return stream;
|
|
511
829
|
}
|
|
512
830
|
|
|
513
|
-
private promptWithAttachments(
|
|
831
|
+
private async promptWithAttachments(
|
|
514
832
|
stream: SessionStream,
|
|
515
833
|
command: Command & { readonly type: "user_message" }
|
|
516
|
-
):
|
|
834
|
+
): Promise<Outcome> {
|
|
835
|
+
// Trimmed once, here: pi dispatches on the text it is handed after the
|
|
836
|
+
// fallthrough trims it, so a client that does not trim would otherwise
|
|
837
|
+
// slip a command past this check and into pi's own.
|
|
838
|
+
const text = command.text.trim();
|
|
839
|
+
// Read before anything is rendered: what a command is, is its own text.
|
|
840
|
+
const dispatched = await this.dispatchCommand(stream, text);
|
|
517
841
|
const taken = this.uploads.take(
|
|
518
842
|
stream.sessionId,
|
|
519
843
|
(command.attachments ?? []).map((ref) => ref.id)
|
|
520
844
|
);
|
|
845
|
+
// A slash command carries no pictures; the uploads are still taken, so the
|
|
846
|
+
// endpoint stops holding their bytes for the rest of the session.
|
|
847
|
+
if (dispatched) {
|
|
848
|
+
return { dispatched: true };
|
|
849
|
+
}
|
|
521
850
|
const { lines, images } = Attachments.render(taken);
|
|
522
|
-
|
|
523
|
-
|
|
851
|
+
this.prompt(stream, [text, ...lines].filter(Boolean).join("\n\n"), images);
|
|
852
|
+
return {};
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Whether `text` names an extension command, having started it if it does.
|
|
857
|
+
* Only the decision is awaited: the handler runs on beyond the ack, exactly
|
|
858
|
+
* as a turn does.
|
|
859
|
+
*/
|
|
860
|
+
private async dispatchCommand(
|
|
861
|
+
stream: SessionStream,
|
|
862
|
+
text: string
|
|
863
|
+
): Promise<boolean> {
|
|
864
|
+
const host = stream.host;
|
|
865
|
+
if (!text.startsWith("/")) {
|
|
866
|
+
return false;
|
|
867
|
+
}
|
|
868
|
+
// The picker offers extension commands only once an agent exists, so this
|
|
869
|
+
// is someone typing from memory; cold is idle, so nothing is queued behind it.
|
|
870
|
+
const agent = host.agentSession ?? (await host.ensureAgent());
|
|
871
|
+
const name = commandNameOf(text);
|
|
872
|
+
if (agent.extensionRunner.getCommand(name) === undefined) {
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
875
|
+
void stream
|
|
876
|
+
.dispatch(`/${name}`, async () => {
|
|
877
|
+
// Prompted whole, never merged into the queue: a merged string starts
|
|
878
|
+
// with the earlier message, and pi reads the leading `/` before it
|
|
879
|
+
// looks at `isStreaming` — so the turn we are holding dispatches it.
|
|
880
|
+
if (host.isStreaming) {
|
|
881
|
+
await agent.prompt(text, {
|
|
882
|
+
streamingBehavior: "steer",
|
|
883
|
+
source: "rpc",
|
|
884
|
+
});
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
await host.run(async (session) => {
|
|
888
|
+
await session.prompt(text, { source: "rpc" });
|
|
889
|
+
});
|
|
890
|
+
})
|
|
891
|
+
.catch((err: unknown) => {
|
|
892
|
+
this.failed(stream, err, "command");
|
|
893
|
+
});
|
|
894
|
+
return true;
|
|
524
895
|
}
|
|
525
896
|
|
|
526
897
|
// Merge into the queued message: pi holds at most one, so a second queued send would be unreachable.
|
|
@@ -532,13 +903,6 @@ export class WsGateway {
|
|
|
532
903
|
const attached = images.length === 0 ? {} : { images: [...images] };
|
|
533
904
|
const host = stream.host;
|
|
534
905
|
const agent = host.agentSession;
|
|
535
|
-
const failed = (err: unknown, what: string): void => {
|
|
536
|
-
console.error(`[gateway] ${what} failed:`, err);
|
|
537
|
-
stream.push({
|
|
538
|
-
type: "error",
|
|
539
|
-
message: (err as Error).message || String(err),
|
|
540
|
-
});
|
|
541
|
-
};
|
|
542
906
|
if (agent && host.isStreaming) {
|
|
543
907
|
const queued = [...host.takeBack(), text].join("\n\n");
|
|
544
908
|
void agent
|
|
@@ -548,7 +912,7 @@ export class WsGateway {
|
|
|
548
912
|
...attached,
|
|
549
913
|
})
|
|
550
914
|
.catch((err: unknown) => {
|
|
551
|
-
failed(err, "steer");
|
|
915
|
+
this.failed(stream, err, "steer");
|
|
552
916
|
});
|
|
553
917
|
return;
|
|
554
918
|
}
|
|
@@ -557,7 +921,15 @@ export class WsGateway {
|
|
|
557
921
|
await session.prompt(text, { source: "rpc", ...attached });
|
|
558
922
|
})
|
|
559
923
|
.catch((err: unknown) => {
|
|
560
|
-
failed(err, "turn");
|
|
924
|
+
this.failed(stream, err, "turn");
|
|
561
925
|
});
|
|
562
926
|
}
|
|
927
|
+
|
|
928
|
+
private failed(stream: SessionStream, err: unknown, what: string): void {
|
|
929
|
+
console.error(`[gateway] ${what} failed:`, err);
|
|
930
|
+
stream.push({
|
|
931
|
+
type: "error",
|
|
932
|
+
message: (err as Error).message || String(err),
|
|
933
|
+
});
|
|
934
|
+
}
|
|
563
935
|
}
|