@oh-my-pi/pi-coding-agent 17.2.5 → 17.2.6
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/CHANGELOG.md +18 -0
- package/dist/{CHANGELOG-q2w43aw3.md → CHANGELOG-gs76k6wc.md} +18 -0
- package/dist/cli.js +3378 -3333
- package/dist/types/cli/gc-cli.d.ts +1 -0
- package/dist/types/launch/client.d.ts +9 -1
- package/dist/types/launch/protocol.d.ts +17 -0
- package/dist/types/modes/components/btw-panel.d.ts +3 -0
- package/dist/types/modes/controllers/btw-controller.d.ts +2 -0
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +4 -1
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/security/contracts/schemas.d.ts +405 -403
- package/dist/types/session/agent-session-types.d.ts +5 -0
- package/dist/types/session/agent-session.d.ts +26 -2
- package/dist/types/session/launch-completion.d.ts +10 -0
- package/dist/types/session/session-entries.d.ts +15 -1
- package/dist/types/session/session-manager.d.ts +7 -0
- package/dist/types/session/yield-queue.d.ts +6 -1
- package/dist/types/tools/index.d.ts +9 -0
- package/package.json +12 -12
- package/src/cli/gc-cli.ts +641 -21
- package/src/config/model-discovery.ts +14 -14
- package/src/config/model-registry.ts +10 -8
- package/src/config/settings.ts +1 -1
- package/src/export/html/index.ts +10 -3
- package/src/export/share.ts +4 -0
- package/src/launch/broker.ts +222 -6
- package/src/launch/client.ts +161 -9
- package/src/launch/protocol.ts +52 -0
- package/src/main.ts +13 -6
- package/src/mcp/config-writer.ts +1 -1
- package/src/modes/components/btw-panel.ts +41 -4
- package/src/modes/controllers/btw-controller.ts +55 -7
- package/src/modes/controllers/command-controller.ts +31 -0
- package/src/modes/controllers/input-controller.ts +24 -7
- package/src/modes/interactive-mode.ts +16 -2
- package/src/modes/types.ts +8 -1
- package/src/prompts/session/launch-completion.md +1 -0
- package/src/sdk.ts +15 -0
- package/src/security/contracts/schemas.ts +205 -183
- package/src/security/contracts/validation.ts +5 -1
- package/src/security/store.ts +2 -2
- package/src/session/agent-session-types.ts +6 -0
- package/src/session/agent-session.ts +191 -14
- package/src/session/launch-completion.ts +37 -0
- package/src/session/session-context.ts +26 -0
- package/src/session/session-entries.ts +17 -1
- package/src/session/session-manager.ts +21 -0
- package/src/session/yield-queue.ts +121 -16
- package/src/slash-commands/builtin-registry.ts +10 -0
- package/src/tools/browser/cmux/cmux-tab.ts +92 -4
- package/src/tools/hub/launch.ts +122 -6
- package/src/tools/index.ts +9 -0
- package/dist/types/config/file-lock.d.ts +0 -29
- package/src/config/file-lock.ts +0 -164
package/src/launch/client.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
|
|
|
2
2
|
import * as net from "node:net";
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
-
import { getGlobalDaemonRuntimeDir, isEexist, isEisdir, isEnoent, postmortem } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import { getGlobalDaemonRuntimeDir, isEexist, isEisdir, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
|
|
6
6
|
import { hostHasInheritableConsole } from "../eval/py/spawn-options";
|
|
7
7
|
import { resolveWorkerSpawnCmd, workerEnvFromParent } from "../subprocess/worker-client";
|
|
8
8
|
import { daemonBrokerEndpoint, daemonRuntimeDir } from "./paths";
|
|
@@ -11,11 +11,12 @@ import {
|
|
|
11
11
|
DAEMON_IDLE_GRACE_ENV,
|
|
12
12
|
DAEMON_PROJECT_DIR_ENV,
|
|
13
13
|
DAEMON_RUNTIME_DIR_ENV,
|
|
14
|
+
type DaemonCompletionNotification,
|
|
14
15
|
type DaemonOperation,
|
|
15
16
|
type DaemonRpcResult,
|
|
16
|
-
type
|
|
17
|
+
type DaemonWireMessage,
|
|
17
18
|
parseDaemonRpcResult,
|
|
18
|
-
|
|
19
|
+
parseDaemonWireMessage,
|
|
19
20
|
} from "./protocol";
|
|
20
21
|
import { resolveDaemonSpawnOptions } from "./spawn-options";
|
|
21
22
|
|
|
@@ -43,14 +44,26 @@ export interface DaemonBrokerClientOptions {
|
|
|
43
44
|
idleGraceMs?: number;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
export interface DaemonCompletionUnregisterOptions {
|
|
48
|
+
/** Detach this process without deleting broker-persisted pending notifications. */
|
|
49
|
+
preservePending?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
46
52
|
/** Persistent per-process connection to one project or global daemon broker. */
|
|
47
53
|
export interface DaemonBrokerClient {
|
|
54
|
+
onCompletion(
|
|
55
|
+
owner: string,
|
|
56
|
+
sink: (notification: DaemonCompletionNotification) => Promise<void> | void,
|
|
57
|
+
): (options?: DaemonCompletionUnregisterOptions) => void;
|
|
48
58
|
/** Canonical project directory or synthetic directory identifying a global scope. */
|
|
49
59
|
readonly projectDir: string;
|
|
50
60
|
request(operation: DaemonOperation, signal?: AbortSignal): Promise<DaemonRpcResult>;
|
|
51
61
|
close(): void;
|
|
52
62
|
}
|
|
53
63
|
|
|
64
|
+
/** A request reached the broker and the broker rejected the operation. */
|
|
65
|
+
export class DaemonBrokerRejectedError extends Error {}
|
|
66
|
+
|
|
54
67
|
async function canonicalProjectDir(projectDir: string): Promise<string> {
|
|
55
68
|
const resolved = path.resolve(projectDir);
|
|
56
69
|
try {
|
|
@@ -134,12 +147,20 @@ class SocketDaemonClient implements DaemonBrokerClient {
|
|
|
134
147
|
readonly #runtimeDir: string;
|
|
135
148
|
readonly #endpoint: string;
|
|
136
149
|
readonly #token: string;
|
|
150
|
+
readonly #seenCompletionIds = new Set<string>();
|
|
137
151
|
readonly #idleGraceMs: number | undefined;
|
|
138
152
|
readonly #pending = new Map<string, PendingRequest>();
|
|
153
|
+
readonly #completionSinks = new Map<string, (notification: DaemonCompletionNotification) => Promise<void> | void>();
|
|
154
|
+
readonly #completionUnsubscribes = new Set<string>();
|
|
155
|
+
readonly #preservedCompletionOwners = new Set<string>();
|
|
156
|
+
readonly #completionReplays = new Set<string>();
|
|
157
|
+
readonly #inFlightCompletionIds = new Set<string>();
|
|
158
|
+
readonly #completionSubscriptionId = crypto.randomUUID();
|
|
139
159
|
#socket: net.Socket | undefined;
|
|
140
160
|
#connectPromise: Promise<void> | undefined;
|
|
141
161
|
#buffer = "";
|
|
142
162
|
#closed = false;
|
|
163
|
+
#completionReconnectTimer: NodeJS.Timeout | undefined;
|
|
143
164
|
|
|
144
165
|
constructor(projectDir: string, runtimeDir: string, token: string, options: DaemonBrokerClientOptions) {
|
|
145
166
|
this.projectDir = projectDir;
|
|
@@ -156,6 +177,8 @@ class SocketDaemonClient implements DaemonBrokerClient {
|
|
|
156
177
|
const socket = this.#socket;
|
|
157
178
|
if (!socket || socket.destroyed) throw new Error("Daemon broker socket is unavailable");
|
|
158
179
|
|
|
180
|
+
const completionUnsubscribes = [...this.#completionUnsubscribes];
|
|
181
|
+
const completionReplays = [...this.#completionReplays];
|
|
159
182
|
const id = crypto.randomUUID();
|
|
160
183
|
const { promise, resolve, reject } = Promise.withResolvers<DaemonRpcResult>();
|
|
161
184
|
const timer = setTimeout(() => {
|
|
@@ -176,18 +199,88 @@ class SocketDaemonClient implements DaemonBrokerClient {
|
|
|
176
199
|
pending.removeAbort = () => signal.removeEventListener("abort", abort);
|
|
177
200
|
}
|
|
178
201
|
this.#pending.set(id, pending);
|
|
179
|
-
socket.write(
|
|
180
|
-
|
|
202
|
+
socket.write(
|
|
203
|
+
`${JSON.stringify({
|
|
204
|
+
id,
|
|
205
|
+
token: this.#token,
|
|
206
|
+
owners: [...this.#completionSinks.keys()],
|
|
207
|
+
detachedOwners: [...this.#preservedCompletionOwners],
|
|
208
|
+
completionEvents: true,
|
|
209
|
+
completionUnsubscribes,
|
|
210
|
+
completionReplays,
|
|
211
|
+
completionSubscriptionId: this.#completionSubscriptionId,
|
|
212
|
+
operation,
|
|
213
|
+
})}\n`,
|
|
214
|
+
);
|
|
215
|
+
const result = await promise;
|
|
216
|
+
for (const owner of completionUnsubscribes) {
|
|
217
|
+
if (!this.#completionSinks.has(owner)) this.#completionUnsubscribes.delete(owner);
|
|
218
|
+
}
|
|
219
|
+
for (const owner of completionReplays) {
|
|
220
|
+
if (this.#completionSinks.has(owner)) this.#completionReplays.delete(owner);
|
|
221
|
+
}
|
|
222
|
+
return result;
|
|
181
223
|
}
|
|
182
224
|
|
|
183
225
|
close(): void {
|
|
184
226
|
if (this.#closed) return;
|
|
185
227
|
this.#closed = true;
|
|
228
|
+
clearTimeout(this.#completionReconnectTimer);
|
|
229
|
+
this.#completionReconnectTimer = undefined;
|
|
186
230
|
this.#socket?.destroy();
|
|
231
|
+
this.#completionSinks.clear();
|
|
232
|
+
this.#preservedCompletionOwners.clear();
|
|
233
|
+
this.#completionReplays.clear();
|
|
187
234
|
this.#socket = undefined;
|
|
188
235
|
this.#rejectPending(new Error("Daemon broker client closed"));
|
|
189
236
|
}
|
|
190
237
|
|
|
238
|
+
onCompletion(
|
|
239
|
+
owner: string,
|
|
240
|
+
sink: (notification: DaemonCompletionNotification) => Promise<void> | void,
|
|
241
|
+
): (options?: DaemonCompletionUnregisterOptions) => void {
|
|
242
|
+
this.#completionUnsubscribes.delete(owner);
|
|
243
|
+
if (this.#preservedCompletionOwners.delete(owner)) this.#completionReplays.add(owner);
|
|
244
|
+
this.#completionSinks.set(owner, sink);
|
|
245
|
+
this.#publishCompletionOwners();
|
|
246
|
+
return options => {
|
|
247
|
+
if (this.#completionSinks.get(owner) !== sink) return;
|
|
248
|
+
this.#completionSinks.delete(owner);
|
|
249
|
+
if (options?.preservePending) {
|
|
250
|
+
this.#preservedCompletionOwners.add(owner);
|
|
251
|
+
} else {
|
|
252
|
+
this.#preservedCompletionOwners.delete(owner);
|
|
253
|
+
this.#completionUnsubscribes.add(owner);
|
|
254
|
+
}
|
|
255
|
+
if (this.#completionSinks.size === 0 && this.#completionReconnectTimer) {
|
|
256
|
+
clearTimeout(this.#completionReconnectTimer);
|
|
257
|
+
this.#completionReconnectTimer = undefined;
|
|
258
|
+
}
|
|
259
|
+
this.#publishCompletionOwners();
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
#publishCompletionOwners(): void {
|
|
264
|
+
if (this.#closed) return;
|
|
265
|
+
void this.request({ op: "ping" }).catch(() => this.#scheduleCompletionReconnect());
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#scheduleCompletionReconnect(): void {
|
|
269
|
+
if (
|
|
270
|
+
this.#closed ||
|
|
271
|
+
this.#completionSinks.size === 0 ||
|
|
272
|
+
this.#completionReconnectTimer !== undefined ||
|
|
273
|
+
(this.#socket !== undefined && !this.#socket.destroyed)
|
|
274
|
+
) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
this.#completionReconnectTimer = setTimeout(() => {
|
|
278
|
+
this.#completionReconnectTimer = undefined;
|
|
279
|
+
this.#publishCompletionOwners();
|
|
280
|
+
}, CONNECT_RETRY_MS);
|
|
281
|
+
this.#completionReconnectTimer.unref();
|
|
282
|
+
}
|
|
283
|
+
|
|
191
284
|
async #connect(): Promise<void> {
|
|
192
285
|
if (this.#socket && !this.#socket.destroyed) return;
|
|
193
286
|
if (this.#connectPromise) return this.#connectPromise;
|
|
@@ -251,6 +344,7 @@ class SocketDaemonClient implements DaemonBrokerClient {
|
|
|
251
344
|
socket.on("close", () => {
|
|
252
345
|
if (this.#socket === socket) this.#socket = undefined;
|
|
253
346
|
this.#rejectPending(new Error("Daemon broker connection closed"));
|
|
347
|
+
this.#scheduleCompletionReconnect();
|
|
254
348
|
});
|
|
255
349
|
}
|
|
256
350
|
|
|
@@ -262,21 +356,32 @@ class SocketDaemonClient implements DaemonBrokerClient {
|
|
|
262
356
|
const line = this.#buffer.slice(0, newline);
|
|
263
357
|
this.#buffer = this.#buffer.slice(newline + 1);
|
|
264
358
|
if (line.length === 0) continue;
|
|
265
|
-
let
|
|
359
|
+
let decoded: unknown;
|
|
360
|
+
try {
|
|
361
|
+
decoded = JSON.parse(line);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
this.#rejectPending(error instanceof Error ? error : new Error(String(error)));
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
let message: DaemonWireMessage;
|
|
266
367
|
try {
|
|
267
|
-
|
|
268
|
-
response = parseDaemonWireResponse(decoded);
|
|
368
|
+
message = parseDaemonWireMessage(decoded);
|
|
269
369
|
} catch (error) {
|
|
270
370
|
this.#rejectPending(error instanceof Error ? error : new Error(String(error)));
|
|
271
371
|
continue;
|
|
272
372
|
}
|
|
373
|
+
if ("event" in message) {
|
|
374
|
+
void this.#deliverCompletion(message);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const response = message;
|
|
273
378
|
const pending = this.#pending.get(response.id);
|
|
274
379
|
if (!pending) continue;
|
|
275
380
|
this.#pending.delete(response.id);
|
|
276
381
|
clearTimeout(pending.timer);
|
|
277
382
|
pending.removeAbort?.();
|
|
278
383
|
if (!response.ok) {
|
|
279
|
-
pending.reject(new
|
|
384
|
+
pending.reject(new DaemonBrokerRejectedError(response.error));
|
|
280
385
|
continue;
|
|
281
386
|
}
|
|
282
387
|
try {
|
|
@@ -287,6 +392,53 @@ class SocketDaemonClient implements DaemonBrokerClient {
|
|
|
287
392
|
}
|
|
288
393
|
}
|
|
289
394
|
|
|
395
|
+
async #deliverCompletion(message: DaemonCompletionNotification): Promise<void> {
|
|
396
|
+
if (this.#seenCompletionIds.has(message.completionId)) {
|
|
397
|
+
this.#ackCompletion(message.completionId);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (this.#inFlightCompletionIds.has(message.completionId)) return;
|
|
401
|
+
const sink = this.#completionSinks.get(message.owner);
|
|
402
|
+
if (!sink) return;
|
|
403
|
+
this.#inFlightCompletionIds.add(message.completionId);
|
|
404
|
+
try {
|
|
405
|
+
await sink(message);
|
|
406
|
+
if (this.#seenCompletionIds.size >= 512) {
|
|
407
|
+
const oldest = this.#seenCompletionIds.values().next().value;
|
|
408
|
+
if (oldest !== undefined) this.#seenCompletionIds.delete(oldest);
|
|
409
|
+
}
|
|
410
|
+
this.#seenCompletionIds.add(message.completionId);
|
|
411
|
+
this.#ackCompletion(message.completionId);
|
|
412
|
+
} catch (error) {
|
|
413
|
+
logger.warn("Daemon completion sink failed", {
|
|
414
|
+
owner: message.owner,
|
|
415
|
+
completionId: message.completionId,
|
|
416
|
+
error: error instanceof Error ? error.message : String(error),
|
|
417
|
+
});
|
|
418
|
+
this.#socket?.destroy();
|
|
419
|
+
} finally {
|
|
420
|
+
this.#inFlightCompletionIds.delete(message.completionId);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
#ackCompletion(completionId: string): void {
|
|
425
|
+
const socket = this.#socket;
|
|
426
|
+
if (!socket || socket.destroyed) return;
|
|
427
|
+
socket.write(
|
|
428
|
+
`${JSON.stringify({
|
|
429
|
+
id: crypto.randomUUID(),
|
|
430
|
+
token: this.#token,
|
|
431
|
+
owners: [...this.#completionSinks.keys()],
|
|
432
|
+
detachedOwners: [...this.#preservedCompletionOwners],
|
|
433
|
+
completionEvents: true,
|
|
434
|
+
completionAcks: [completionId],
|
|
435
|
+
completionUnsubscribes: [...this.#completionUnsubscribes],
|
|
436
|
+
completionSubscriptionId: this.#completionSubscriptionId,
|
|
437
|
+
operation: { op: "ping" },
|
|
438
|
+
})}\n`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
290
442
|
#rejectPending(error: Error): void {
|
|
291
443
|
for (const pending of this.#pending.values()) {
|
|
292
444
|
clearTimeout(pending.timer);
|
package/src/launch/protocol.ts
CHANGED
|
@@ -122,12 +122,29 @@ export type DaemonRpcResult =
|
|
|
122
122
|
export interface DaemonWireRequest {
|
|
123
123
|
id: string;
|
|
124
124
|
token: string;
|
|
125
|
+
owners?: string[];
|
|
126
|
+
detachedOwners?: string[];
|
|
127
|
+
completionEvents?: boolean;
|
|
128
|
+
completionAcks?: string[];
|
|
129
|
+
completionUnsubscribes?: string[];
|
|
130
|
+
completionReplays?: string[];
|
|
131
|
+
completionSubscriptionId?: string;
|
|
125
132
|
operation: DaemonOperation;
|
|
126
133
|
}
|
|
127
134
|
|
|
128
135
|
/** Response envelope kept raw until matched with its pending operation. */
|
|
129
136
|
export type DaemonWireResponse = { id: string; ok: true; result: unknown } | { id: string; ok: false; error: string };
|
|
130
137
|
|
|
138
|
+
/** Unsolicited terminal completion sent to the socket that owns a daemon. */
|
|
139
|
+
export interface DaemonCompletionNotification {
|
|
140
|
+
event: "daemon-completed";
|
|
141
|
+
completionId: string;
|
|
142
|
+
owner: string;
|
|
143
|
+
daemon: DaemonSnapshot;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type DaemonWireMessage = DaemonWireResponse | DaemonCompletionNotification;
|
|
147
|
+
|
|
131
148
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
132
149
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
133
150
|
}
|
|
@@ -268,6 +285,27 @@ export function parseDaemonWireRequest(value: unknown): DaemonWireRequest {
|
|
|
268
285
|
return {
|
|
269
286
|
id: stringValue(source.id, "request.id"),
|
|
270
287
|
token: stringValue(source.token, "request.token"),
|
|
288
|
+
owners: source.owners === undefined ? undefined : stringArray(source.owners, "request.owners"),
|
|
289
|
+
detachedOwners:
|
|
290
|
+
source.detachedOwners === undefined ? undefined : stringArray(source.detachedOwners, "request.detachedOwners"),
|
|
291
|
+
completionEvents:
|
|
292
|
+
source.completionEvents === undefined
|
|
293
|
+
? undefined
|
|
294
|
+
: booleanValue(source.completionEvents, "request.completionEvents"),
|
|
295
|
+
completionAcks:
|
|
296
|
+
source.completionAcks === undefined ? undefined : stringArray(source.completionAcks, "request.completionAcks"),
|
|
297
|
+
completionUnsubscribes:
|
|
298
|
+
source.completionUnsubscribes === undefined
|
|
299
|
+
? undefined
|
|
300
|
+
: stringArray(source.completionUnsubscribes, "request.completionUnsubscribes"),
|
|
301
|
+
completionReplays:
|
|
302
|
+
source.completionReplays === undefined
|
|
303
|
+
? undefined
|
|
304
|
+
: stringArray(source.completionReplays, "request.completionReplays"),
|
|
305
|
+
completionSubscriptionId:
|
|
306
|
+
source.completionSubscriptionId === undefined
|
|
307
|
+
? undefined
|
|
308
|
+
: stringValue(source.completionSubscriptionId, "request.completionSubscriptionId"),
|
|
271
309
|
operation: parseDaemonOperation(source.operation),
|
|
272
310
|
};
|
|
273
311
|
}
|
|
@@ -281,6 +319,20 @@ export function parseDaemonWireResponse(value: unknown): DaemonWireResponse {
|
|
|
281
319
|
throw new Error("response.ok must be a boolean");
|
|
282
320
|
}
|
|
283
321
|
|
|
322
|
+
/** Decode one broker response or unsolicited completion notification. */
|
|
323
|
+
export function parseDaemonWireMessage(value: unknown): DaemonWireMessage {
|
|
324
|
+
const source = record(value, "daemon message");
|
|
325
|
+
if (source.event === "daemon-completed") {
|
|
326
|
+
return {
|
|
327
|
+
event: "daemon-completed",
|
|
328
|
+
completionId: stringValue(source.completionId, "completion.id"),
|
|
329
|
+
owner: stringValue(source.owner, "completion.owner"),
|
|
330
|
+
daemon: parseDaemonSnapshot(source.daemon),
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
return parseDaemonWireResponse(value);
|
|
334
|
+
}
|
|
335
|
+
|
|
284
336
|
function parseDaemonOperation(value: unknown): DaemonOperation {
|
|
285
337
|
const source = record(value, "daemon operation");
|
|
286
338
|
const op = stringValue(source.op, "operation.op");
|
package/src/main.ts
CHANGED
|
@@ -1597,6 +1597,18 @@ export async function runRootCommand(
|
|
|
1597
1597
|
stdoutIsTTY: process.stdout.isTTY,
|
|
1598
1598
|
});
|
|
1599
1599
|
|
|
1600
|
+
// Startup changelog is only consumed by interactive mode below; kick the
|
|
1601
|
+
// CHANGELOG.md parse off now so it overlaps session creation instead of
|
|
1602
|
+
// serializing after it.
|
|
1603
|
+
const startupChangelogPromise = isInteractive
|
|
1604
|
+
? logger.time(
|
|
1605
|
+
"main:getChangelogForDisplay",
|
|
1606
|
+
getChangelogForDisplay,
|
|
1607
|
+
parsedArgs,
|
|
1608
|
+
settingsInstance.get("startup.changelogMode"),
|
|
1609
|
+
)
|
|
1610
|
+
: undefined;
|
|
1611
|
+
|
|
1600
1612
|
const { session, setToolUIContext, modelFallbackMessage, lspServers, mcpManager } = await createSession({
|
|
1601
1613
|
...sessionOptions,
|
|
1602
1614
|
eventBus,
|
|
@@ -1656,12 +1668,7 @@ export async function runRootCommand(
|
|
|
1656
1668
|
await runRpcMode(session, mode === "rpc-ui" ? setToolUIContext : undefined, eventBus, rpcInput);
|
|
1657
1669
|
} else if (isInteractive) {
|
|
1658
1670
|
const versionCheckPromise = checkForNewVersion(VERSION).catch(() => undefined);
|
|
1659
|
-
const startupChangelog = await
|
|
1660
|
-
"main:getChangelogForDisplay",
|
|
1661
|
-
getChangelogForDisplay,
|
|
1662
|
-
parsedArgs,
|
|
1663
|
-
settingsInstance.get("startup.changelogMode"),
|
|
1664
|
-
);
|
|
1671
|
+
const startupChangelog = await startupChangelogPromise;
|
|
1665
1672
|
|
|
1666
1673
|
const modelScopeNotification = buildModelScopeNotification(
|
|
1667
1674
|
scopedModels,
|
package/src/mcp/config-writer.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { randomUUID } from "node:crypto";
|
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
import { isEnoent } from "@oh-my-pi/pi-utils";
|
|
10
|
+
import { withFileLock } from "@oh-my-pi/pi-utils/file-lock";
|
|
10
11
|
import { invalidate as invalidateFsCache } from "../capability/fs";
|
|
11
|
-
import { withFileLock } from "../config/file-lock";
|
|
12
12
|
|
|
13
13
|
import { validateServerConfig } from "./config";
|
|
14
14
|
import { MCP_CONFIG_SCHEMA_URL, type MCPConfigFile, type MCPServerConfig } from "./types";
|
|
@@ -3,16 +3,37 @@ import { replaceTabs } from "../../tools/render-utils";
|
|
|
3
3
|
import { getMarkdownTheme, theme } from "../theme/theme";
|
|
4
4
|
import { DynamicBorder } from "./dynamic-border";
|
|
5
5
|
|
|
6
|
-
type BtwPanelState = "running" | "complete" | "aborted" | "error";
|
|
6
|
+
type BtwPanelState = "running" | "complete" | "branching" | "aborted" | "error";
|
|
7
7
|
|
|
8
8
|
interface BtwPanelComponentOptions {
|
|
9
9
|
question: string;
|
|
10
10
|
tui: TUI;
|
|
11
|
+
canBranch?: () => boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
class BtwFooter implements Component {
|
|
15
|
+
#getLine: () => string;
|
|
16
|
+
#line: string | undefined;
|
|
17
|
+
#text: Text | undefined;
|
|
18
|
+
|
|
19
|
+
constructor(getLine: () => string) {
|
|
20
|
+
this.#getLine = getLine;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
render(width: number): readonly string[] {
|
|
24
|
+
const line = this.#getLine();
|
|
25
|
+
if (line !== this.#line || !this.#text) {
|
|
26
|
+
this.#line = line;
|
|
27
|
+
this.#text = new Text(line, 1, 0);
|
|
28
|
+
}
|
|
29
|
+
return this.#text.render(width);
|
|
30
|
+
}
|
|
11
31
|
}
|
|
12
32
|
|
|
13
33
|
export class BtwPanelComponent extends Container {
|
|
14
34
|
#question: string;
|
|
15
35
|
#tui: TUI;
|
|
36
|
+
#canBranch: (() => boolean) | undefined;
|
|
16
37
|
#state: BtwPanelState = "running";
|
|
17
38
|
#answer = "";
|
|
18
39
|
#errorMessage: string | undefined;
|
|
@@ -23,6 +44,7 @@ export class BtwPanelComponent extends Container {
|
|
|
23
44
|
super();
|
|
24
45
|
this.#question = options.question;
|
|
25
46
|
this.#tui = options.tui;
|
|
47
|
+
this.#canBranch = options.canBranch;
|
|
26
48
|
this.#rebuild();
|
|
27
49
|
}
|
|
28
50
|
|
|
@@ -47,6 +69,14 @@ export class BtwPanelComponent extends Container {
|
|
|
47
69
|
this.#rebuild();
|
|
48
70
|
}
|
|
49
71
|
|
|
72
|
+
/** Shows that the completed answer is being promoted into the chat session. */
|
|
73
|
+
markBranching(): void {
|
|
74
|
+
if (this.#closed) return;
|
|
75
|
+
this.#state = "branching";
|
|
76
|
+
this.#errorMessage = undefined;
|
|
77
|
+
this.#rebuild();
|
|
78
|
+
}
|
|
79
|
+
|
|
50
80
|
markAborted(): void {
|
|
51
81
|
if (this.#closed) return;
|
|
52
82
|
this.#state = "aborted";
|
|
@@ -86,7 +116,7 @@ export class BtwPanelComponent extends Container {
|
|
|
86
116
|
this.addChild(new Spacer(1));
|
|
87
117
|
this.addChild(this.#contentComponent());
|
|
88
118
|
this.addChild(new Spacer(1));
|
|
89
|
-
this.addChild(new
|
|
119
|
+
this.addChild(new BtwFooter(() => this.#footerLine()));
|
|
90
120
|
this.addChild(new Spacer(1));
|
|
91
121
|
this.addChild(new DynamicBorder(str => theme.fg("dim", str)));
|
|
92
122
|
// Component-scoped: a rebuild replaces only this panel's own children
|
|
@@ -100,8 +130,15 @@ export class BtwPanelComponent extends Container {
|
|
|
100
130
|
switch (this.#state) {
|
|
101
131
|
case "running":
|
|
102
132
|
return theme.fg("muted", "Esc cancel /btw");
|
|
103
|
-
case "complete":
|
|
104
|
-
|
|
133
|
+
case "complete": {
|
|
134
|
+
if (!this.isCopyable()) return theme.fg("muted", "Esc dismiss");
|
|
135
|
+
const actions = ["c copy"];
|
|
136
|
+
if (this.#canBranch?.() ?? this.isBranchable()) actions.push("b branch to chat");
|
|
137
|
+
actions.push("Esc dismiss");
|
|
138
|
+
return theme.fg("muted", actions.join(" · "));
|
|
139
|
+
}
|
|
140
|
+
case "branching":
|
|
141
|
+
return theme.fg("muted", `${theme.status.pending} Branching to chat…`);
|
|
105
142
|
case "aborted":
|
|
106
143
|
return theme.fg("warning", `${theme.status.warning} Cancelled · Esc dismiss`);
|
|
107
144
|
case "error":
|
|
@@ -10,6 +10,7 @@ interface BtwRequest {
|
|
|
10
10
|
abortController: AbortController;
|
|
11
11
|
question: string;
|
|
12
12
|
leafId: string | null;
|
|
13
|
+
sessionId: string;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
function assistantMessageWithReplyText(assistantMessage: AssistantMessage, replyText: string): AssistantMessage {
|
|
@@ -39,6 +40,7 @@ export class BtwController {
|
|
|
39
40
|
#lastReplyText: string | undefined;
|
|
40
41
|
#lastAssistantMessage: AssistantMessage | undefined;
|
|
41
42
|
#lastLeafId: string | null | undefined;
|
|
43
|
+
#lastSessionId: string | undefined;
|
|
42
44
|
#branchInFlight = false;
|
|
43
45
|
#lastCopyText: string | undefined;
|
|
44
46
|
#copyInFlight = false;
|
|
@@ -50,17 +52,39 @@ export class BtwController {
|
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
canBranch(): boolean {
|
|
55
|
+
return this.#branchUnavailableReason() === undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether plain `b` is currently reserved for a completed or pending branch action. */
|
|
59
|
+
handlesBranchKey(): boolean {
|
|
60
|
+
if (this.#branchInFlight) return true;
|
|
61
|
+
if (this.#activeRequest?.component.isBranchable() !== true) return false;
|
|
53
62
|
return (
|
|
54
|
-
!this.#branchInFlight &&
|
|
55
|
-
this.#activeRequest?.component.isBranchable() === true &&
|
|
56
63
|
this.#lastQuestion !== undefined &&
|
|
57
64
|
this.#lastReplyText !== undefined &&
|
|
58
65
|
this.#lastAssistantMessage !== undefined &&
|
|
59
|
-
this.#lastLeafId !==
|
|
60
|
-
this.#
|
|
66
|
+
this.#lastLeafId !== undefined &&
|
|
67
|
+
this.#lastSessionId !== undefined
|
|
61
68
|
);
|
|
62
69
|
}
|
|
63
70
|
|
|
71
|
+
#branchUnavailableReason(): string | undefined {
|
|
72
|
+
if (this.#branchInFlight) return "a branch is already in progress";
|
|
73
|
+
if (this.#activeRequest?.component.isBranchable() !== true) return "the answer is not ready";
|
|
74
|
+
if (!this.#lastQuestion || !this.#lastReplyText || !this.#lastAssistantMessage) {
|
|
75
|
+
return "the answer is unavailable";
|
|
76
|
+
}
|
|
77
|
+
if (!this.#lastLeafId) return "the session has no branch point";
|
|
78
|
+
if (
|
|
79
|
+
this.#lastSessionId !== this.ctx.sessionManager.getSessionId() ||
|
|
80
|
+
this.#lastLeafId !== this.ctx.sessionManager.getLeafId()
|
|
81
|
+
) {
|
|
82
|
+
return "the session changed since /btw started";
|
|
83
|
+
}
|
|
84
|
+
if (this.ctx.session.isStreaming) return "a turn is still running";
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
64
88
|
canCopy(): boolean {
|
|
65
89
|
return (
|
|
66
90
|
!this.#copyInFlight && this.#activeRequest?.component.isCopyable() === true && this.#lastCopyText !== undefined
|
|
@@ -83,17 +107,34 @@ export class BtwController {
|
|
|
83
107
|
}
|
|
84
108
|
|
|
85
109
|
async handleBranch(): Promise<boolean> {
|
|
86
|
-
|
|
110
|
+
const unavailableReason = this.#branchUnavailableReason();
|
|
111
|
+
if (unavailableReason) {
|
|
112
|
+
this.ctx.showStatus(`/btw branch unavailable: ${unavailableReason}`, { dim: true });
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
const request = this.#activeRequest;
|
|
116
|
+
const question = this.#lastQuestion;
|
|
117
|
+
const assistantMessage = this.#lastAssistantMessage;
|
|
118
|
+
const leafId = this.#lastLeafId;
|
|
119
|
+
const sessionId = this.#lastSessionId;
|
|
120
|
+
if (!request || !question || !assistantMessage || !leafId || !sessionId) return false;
|
|
121
|
+
|
|
87
122
|
this.#branchInFlight = true;
|
|
123
|
+
request.component.markBranching();
|
|
88
124
|
try {
|
|
89
|
-
await this.ctx.handleBtwBranch(
|
|
125
|
+
await this.ctx.handleBtwBranch(question, assistantMessage, leafId, sessionId);
|
|
90
126
|
return true;
|
|
91
127
|
} finally {
|
|
92
128
|
this.#branchInFlight = false;
|
|
129
|
+
if (this.#activeRequest === request) request.component.markComplete();
|
|
93
130
|
}
|
|
94
131
|
}
|
|
95
132
|
|
|
96
133
|
handleEscape(): boolean {
|
|
134
|
+
if (this.#branchInFlight) {
|
|
135
|
+
this.ctx.showStatus("/btw branch is in progress", { dim: true });
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
97
138
|
if (!this.#activeRequest) return false;
|
|
98
139
|
this.#closeActiveRequest({ abort: this.#activeRequest.abortController.signal.aborted === false });
|
|
99
140
|
return true;
|
|
@@ -119,10 +160,15 @@ export class BtwController {
|
|
|
119
160
|
this.#closeActiveRequest({ abort: true });
|
|
120
161
|
|
|
121
162
|
const request: BtwRequest = {
|
|
122
|
-
component: new BtwPanelComponent({
|
|
163
|
+
component: new BtwPanelComponent({
|
|
164
|
+
question: trimmedQuestion,
|
|
165
|
+
tui: this.ctx.ui,
|
|
166
|
+
canBranch: () => this.canBranch(),
|
|
167
|
+
}),
|
|
123
168
|
abortController: new AbortController(),
|
|
124
169
|
question: trimmedQuestion,
|
|
125
170
|
leafId: this.ctx.sessionManager.getLeafId(),
|
|
171
|
+
sessionId: this.ctx.sessionManager.getSessionId(),
|
|
126
172
|
};
|
|
127
173
|
this.ctx.btwContainer.clear();
|
|
128
174
|
this.ctx.btwContainer.addChild(request.component);
|
|
@@ -156,6 +202,7 @@ export class BtwController {
|
|
|
156
202
|
this.#lastCopyText = copyText;
|
|
157
203
|
this.#lastAssistantMessage = assistantMessageWithReplyText(assistantMessage, replyText);
|
|
158
204
|
this.#lastLeafId = request.leafId;
|
|
205
|
+
this.#lastSessionId = request.sessionId;
|
|
159
206
|
} else {
|
|
160
207
|
this.#clearCompletedState();
|
|
161
208
|
}
|
|
@@ -190,6 +237,7 @@ export class BtwController {
|
|
|
190
237
|
this.#lastAssistantMessage = undefined;
|
|
191
238
|
this.#lastCopyText = undefined;
|
|
192
239
|
this.#lastLeafId = undefined;
|
|
240
|
+
this.#lastSessionId = undefined;
|
|
193
241
|
}
|
|
194
242
|
|
|
195
243
|
#isActiveRequest(request: BtwRequest): boolean {
|
|
@@ -961,6 +961,37 @@ export class CommandController {
|
|
|
961
961
|
this.ctx.showStatus(`Fresh provider session started (${result.closedProviderSessions} ${stateLabel} pruned).`);
|
|
962
962
|
}
|
|
963
963
|
|
|
964
|
+
async handleResetContextCommand(): Promise<void> {
|
|
965
|
+
if (this.ctx.session.isCompacting) {
|
|
966
|
+
this.ctx.session.abortCompaction();
|
|
967
|
+
while (this.ctx.session.isCompacting) {
|
|
968
|
+
await Bun.sleep(10);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
const result = await this.ctx.session.resetSessionContext();
|
|
972
|
+
if (!result) {
|
|
973
|
+
this.ctx.showWarning("Wait for the current response to finish or abort it before resetting the context.");
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
// Drop the rendered transcript so the UI matches the now-empty model
|
|
977
|
+
// context (mirrors #runNewSessionFlow's teardown, minus the new session —
|
|
978
|
+
// the session id, title, and transcript file all survive).
|
|
979
|
+
this.ctx.clearTransientSessionUi();
|
|
980
|
+
this.ctx.resetTranscript();
|
|
981
|
+
this.ctx.statusLine.invalidate();
|
|
982
|
+
this.ctx.updateEditorBorderColor();
|
|
983
|
+
const noun = result.droppedCount === 1 ? "message" : "messages";
|
|
984
|
+
this.ctx.present([
|
|
985
|
+
new Spacer(1),
|
|
986
|
+
new Text(
|
|
987
|
+
`${theme.fg("accent", `${theme.status.success} Context reset — ${result.droppedCount} ${noun} dropped; session continues.`)}`,
|
|
988
|
+
1,
|
|
989
|
+
1,
|
|
990
|
+
),
|
|
991
|
+
]);
|
|
992
|
+
this.ctx.ui.requestRender(true, { clearScrollback: true });
|
|
993
|
+
}
|
|
994
|
+
|
|
964
995
|
async handleDropCommand(): Promise<void> {
|
|
965
996
|
if (!this.ctx.sessionManager.getSessionFile()) {
|
|
966
997
|
this.ctx.showError("Nothing to drop (in-memory session)");
|