paseo-dsh-direct 0.2.9 → 0.2.11
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/README.md +1 -1
- package/README.zh.md +1 -1
- package/client/settings-screen.tsx +4 -4
- package/package.json +1 -1
- package/server/bridge-client.ts +12 -1
- package/server/dsh/service.ts +110 -25
- package/server/provider.ts +12 -1
- package/server/timeline.ts +8 -1
- package/shared/bridge-protocol.ts +4 -1
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ Most dsh integrations go through ACP, a generic protocol that every agent can sp
|
|
|
33
33
|
- Your existing dsh sessions can be imported and resumed in Paseo.
|
|
34
34
|
- dsh's slash commands (`/compact`, `/plan`, ...) appear in Paseo's `/` menu.
|
|
35
35
|
- Paseo's usage meter shows dsh's token counts and how full the context window is.
|
|
36
|
-
- Goal changes, plan mode switches and permission preset changes show up in the conversation as small timeline cards.
|
|
36
|
+
- Goal changes that dsh does not already report in a `/goal` command's own output, plan mode switches and permission preset changes show up in the conversation as small timeline cards.
|
|
37
37
|
|
|
38
38
|
## Versioning
|
|
39
39
|
|
package/README.zh.md
CHANGED
|
@@ -33,7 +33,7 @@ paseo plugin update paseo-dsh-direct --yes # 更新到最新
|
|
|
33
33
|
- 已有的 dsh 会话可以导入 Paseo 接着用。
|
|
34
34
|
- dsh 的斜杠命令(`/compact`、`/plan` 等)出现在 Paseo 的 `/` 菜单里。
|
|
35
35
|
- Paseo 的用量表显示 dsh 的 token 用量和上下文占用。
|
|
36
|
-
-
|
|
36
|
+
- 目标变化(dsh 自己在 `/goal` 命令输出里已报告的除外)、计划模式切换和权限预设变化会在对话里显示为小的时间线卡片。
|
|
37
37
|
|
|
38
38
|
## 版本号
|
|
39
39
|
|
|
@@ -5,9 +5,9 @@ import { useRef } from "react";
|
|
|
5
5
|
import { DSH_SETTINGS } from "../shared/settings.js";
|
|
6
6
|
|
|
7
7
|
const EXECUTABLE_HINT =
|
|
8
|
-
"Path or command of dsh. PASEO_DSH_EXECUTABLE overrides this; leave it empty to use the Paseo provider command or dsh on PATH.
|
|
8
|
+
"Path or command of dsh. PASEO_DSH_EXECUTABLE overrides this; leave it empty to use the Paseo provider command or dsh on PATH.";
|
|
9
9
|
const PROFILE_HINT =
|
|
10
|
-
'Name of the dsh profile to launch. PASEO_DSH_PROFILE overrides this; leave it empty to use the "paseo" profile.
|
|
10
|
+
'Name of the dsh profile to launch. PASEO_DSH_PROFILE overrides this; leave it empty to use the "paseo" profile.';
|
|
11
11
|
|
|
12
12
|
/** Edits the dsh executable and profile that the server reads when it launches a bridge. */
|
|
13
13
|
export default function DshSettingsScreen() {
|
|
@@ -88,8 +88,8 @@ export default function DshSettingsScreen() {
|
|
|
88
88
|
disabled={settings.saving}
|
|
89
89
|
/>
|
|
90
90
|
<SettingsAction
|
|
91
|
-
label="
|
|
92
|
-
hint={settings.saveError ??
|
|
91
|
+
label="Apply settings"
|
|
92
|
+
hint={settings.saveError ?? "Changes apply to new sessions."}
|
|
93
93
|
error={settings.saveError}
|
|
94
94
|
actionLabel={settings.saving ? "Saving" : "Save"}
|
|
95
95
|
onPress={save}
|
package/package.json
CHANGED
package/server/bridge-client.ts
CHANGED
|
@@ -17,6 +17,15 @@ import type {
|
|
|
17
17
|
type Method = keyof BridgeMethods;
|
|
18
18
|
type Notification = keyof BridgeNotifications;
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Default deadline for one bridge request. `session.list` title folds decode a
|
|
22
|
+
* whole session log per row, so callers list large histories with a wider
|
|
23
|
+
* deadline instead of raising this for every method.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
26
|
+
/** Deadline for listing sessions: a cold title fold costs seconds per hundred sessions. */
|
|
27
|
+
export const LIST_SESSIONS_TIMEOUT_MS = 120_000;
|
|
28
|
+
|
|
20
29
|
export interface DshBridge {
|
|
21
30
|
readonly initialized: BridgeInitializeResult;
|
|
22
31
|
/** The resolved `dsh` executable this bridge was launched with (see `resolveDshLaunch`). */
|
|
@@ -26,6 +35,7 @@ export interface DshBridge {
|
|
|
26
35
|
request<Name extends Method>(
|
|
27
36
|
method: Name,
|
|
28
37
|
params: BridgeMethods[Name]["params"],
|
|
38
|
+
timeoutMs?: number,
|
|
29
39
|
): Promise<BridgeMethods[Name]["result"]>;
|
|
30
40
|
on<Name extends Notification>(
|
|
31
41
|
method: Name,
|
|
@@ -471,6 +481,7 @@ class JsonlBridgeClient implements DshBridge {
|
|
|
471
481
|
request<Name extends Method>(
|
|
472
482
|
method: Name,
|
|
473
483
|
params: BridgeMethods[Name]["params"],
|
|
484
|
+
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
474
485
|
): Promise<BridgeMethods[Name]["result"]> {
|
|
475
486
|
if (this.terminalError) return Promise.reject(this.terminalError);
|
|
476
487
|
if (this.closing && method !== "bridge.shutdown") {
|
|
@@ -481,7 +492,7 @@ class JsonlBridgeClient implements DshBridge {
|
|
|
481
492
|
const timeout = setTimeout(() => {
|
|
482
493
|
this.pending.delete(id);
|
|
483
494
|
reject(new Error(`DSH bridge request timed out: ${method}`));
|
|
484
|
-
},
|
|
495
|
+
}, timeoutMs);
|
|
485
496
|
this.pending.set(id, { resolve, reject, timeout });
|
|
486
497
|
const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
|
|
487
498
|
this.child.stdin.write(payload, (error) => {
|
package/server/dsh/service.ts
CHANGED
|
@@ -140,6 +140,8 @@ export class BridgeService {
|
|
|
140
140
|
private readonly subscriptions: Array<() => void> = [];
|
|
141
141
|
/** Last usage payload sent per session, so an unchanged fold is not re-sent. */
|
|
142
142
|
private readonly lastUsage = new Map<string, string>();
|
|
143
|
+
/** Folded titles per session id, so repeated import listings stay cheap. */
|
|
144
|
+
private readonly titles = new Map<string, string>();
|
|
143
145
|
private stopping = false;
|
|
144
146
|
private stopTask?: Promise<{}>;
|
|
145
147
|
|
|
@@ -262,7 +264,11 @@ export class BridgeService {
|
|
|
262
264
|
}
|
|
263
265
|
| undefined;
|
|
264
266
|
const pressure = values.contextPressure as
|
|
265
|
-
| {
|
|
267
|
+
| {
|
|
268
|
+
contextWindow?: number;
|
|
269
|
+
pressureTokens?: number;
|
|
270
|
+
projectedTokens?: number;
|
|
271
|
+
}
|
|
266
272
|
| undefined;
|
|
267
273
|
const usage: BridgeUsage = {};
|
|
268
274
|
if (typeof tokens?.uncachedInputTokens === "number")
|
|
@@ -271,8 +277,16 @@ export class BridgeService {
|
|
|
271
277
|
usage.cachedInputTokens = tokens.cacheReadTokens;
|
|
272
278
|
if (typeof tokens?.outputTokens === "number")
|
|
273
279
|
usage.outputTokens = tokens.outputTokens;
|
|
274
|
-
|
|
275
|
-
|
|
280
|
+
// `projectedTokens` folds the shadow price of an adjacent compaction into
|
|
281
|
+
// the last request's prompt size, so the meter drops as soon as a
|
|
282
|
+
// compaction lands instead of waiting for the next request. dsh before
|
|
283
|
+
// 0.2.1 writes no shadow price and the field is absent: fall back to the
|
|
284
|
+
// raw prompt size.
|
|
285
|
+
const used =
|
|
286
|
+
typeof pressure?.projectedTokens === "number"
|
|
287
|
+
? pressure.projectedTokens
|
|
288
|
+
: pressure?.pressureTokens;
|
|
289
|
+
if (typeof used === "number") usage.contextWindowUsedTokens = used;
|
|
276
290
|
if (typeof pressure?.contextWindow === "number")
|
|
277
291
|
usage.contextWindowMaxTokens = pressure.contextWindow;
|
|
278
292
|
// The units materialize at zero before any provider usage lands, so a
|
|
@@ -453,29 +467,9 @@ export class BridgeService {
|
|
|
453
467
|
} satisfies BridgeInitializeResult;
|
|
454
468
|
break;
|
|
455
469
|
}
|
|
456
|
-
case "session.list":
|
|
457
|
-
|
|
458
|
-
const records = await query.listSessions();
|
|
459
|
-
const sessions: SessionSummary[] = records.map((record) => ({
|
|
460
|
-
...record.header,
|
|
461
|
-
}));
|
|
462
|
-
// The official bulk title read owns live/cold source and validation.
|
|
463
|
-
if (typeof query.readTitleSnapshots === "function") {
|
|
464
|
-
const titles = await query.readTitleSnapshots(
|
|
465
|
-
sessions.map((session) => session.id),
|
|
466
|
-
);
|
|
467
|
-
for (let i = 0; i < sessions.length; i++) {
|
|
468
|
-
const result = titles[i];
|
|
469
|
-
const title =
|
|
470
|
-
result?.status === "fulfilled"
|
|
471
|
-
? result.value.title?.title
|
|
472
|
-
: undefined;
|
|
473
|
-
if (typeof title === "string") sessions[i]!.title = title;
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
result = { sessions };
|
|
470
|
+
case "session.list":
|
|
471
|
+
result = { sessions: await this.listSessions(listParams(params)) };
|
|
477
472
|
break;
|
|
478
|
-
}
|
|
479
473
|
case "session.read":
|
|
480
474
|
result = await this.ctx
|
|
481
475
|
.get("sessionQuery")
|
|
@@ -622,6 +616,72 @@ export class BridgeService {
|
|
|
622
616
|
);
|
|
623
617
|
}
|
|
624
618
|
|
|
619
|
+
/**
|
|
620
|
+
* List native sessions for Paseo's import picker.
|
|
621
|
+
*
|
|
622
|
+
* `readTitleSnapshots` decodes each requested session's log to fold its
|
|
623
|
+
* title, which costs seconds per hundred sessions on a large history, so
|
|
624
|
+
* titles are folded only for the rows this call returns: cwd and query
|
|
625
|
+
* filter first, then newest first, then the limit. Folded titles are cached
|
|
626
|
+
* for this bridge's lifetime; a title renamed outside Paseo keeps its cached
|
|
627
|
+
* value until the bridge is replaced, while live sessions carry their header
|
|
628
|
+
* title regardless.
|
|
629
|
+
*/
|
|
630
|
+
private async listSessions(
|
|
631
|
+
params: { cwd?: string; query?: string; limit?: number },
|
|
632
|
+
): Promise<SessionSummary[]> {
|
|
633
|
+
const query = this.ctx.get("sessionQuery");
|
|
634
|
+
const records = await query.listSessions();
|
|
635
|
+
let sessions = records
|
|
636
|
+
.map((record) => ({ ...record.header }))
|
|
637
|
+
.filter((session) => !params.cwd || session.cwd === params.cwd);
|
|
638
|
+
if (params.query) {
|
|
639
|
+
// A title query needs every title, so this path folds the whole history.
|
|
640
|
+
await this.foldTitles(query, sessions);
|
|
641
|
+
const needle = params.query.toLowerCase();
|
|
642
|
+
sessions = sessions.filter(
|
|
643
|
+
(session) =>
|
|
644
|
+
session.title?.toLowerCase().includes(needle) ||
|
|
645
|
+
session.id.toLowerCase().includes(needle),
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
sessions.sort((left, right) => right.createdAt - left.createdAt);
|
|
649
|
+
if (params.limit !== undefined && sessions.length > params.limit)
|
|
650
|
+
sessions = sessions.slice(0, params.limit);
|
|
651
|
+
await this.foldTitles(query, sessions);
|
|
652
|
+
return sessions;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** Fill in cached titles, reading only the sessions this bridge has not folded yet. */
|
|
656
|
+
private async foldTitles(
|
|
657
|
+
query: HostServices["sessionQuery"],
|
|
658
|
+
sessions: SessionSummary[],
|
|
659
|
+
): Promise<void> {
|
|
660
|
+
if (typeof query.readTitleSnapshots !== "function") return;
|
|
661
|
+
// The official bulk title read owns live/cold source and validation. A
|
|
662
|
+
// header that already carries a title is live and needs no fold at all.
|
|
663
|
+
const missing = sessions.filter(
|
|
664
|
+
(session) => !session.title && !this.titles.has(session.id),
|
|
665
|
+
);
|
|
666
|
+
if (missing.length > 0) {
|
|
667
|
+
const titles = await query.readTitleSnapshots(
|
|
668
|
+
missing.map((session) => session.id),
|
|
669
|
+
);
|
|
670
|
+
for (let i = 0; i < missing.length; i++) {
|
|
671
|
+
const result = titles[i];
|
|
672
|
+
const title =
|
|
673
|
+
result?.status === "fulfilled"
|
|
674
|
+
? result.value.title?.title
|
|
675
|
+
: undefined;
|
|
676
|
+
if (typeof title === "string") this.titles.set(missing[i]!.id, title);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
for (const session of sessions) {
|
|
680
|
+
const title = this.titles.get(session.id);
|
|
681
|
+
if (title !== undefined) session.title = title;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
625
685
|
/** Registered commands first; a skill never shadows a command of the same name. */
|
|
626
686
|
private async slashCommands(agent: HostAgent): Promise<SlashCommand[]> {
|
|
627
687
|
const commands: SlashCommand[] = (this.ctx.get("commands")?.list(agent) ?? []).map(
|
|
@@ -971,6 +1031,31 @@ function validateContent(value: unknown): PromptPart[] {
|
|
|
971
1031
|
return parts;
|
|
972
1032
|
}
|
|
973
1033
|
|
|
1034
|
+
function listParams(params: Record<string, unknown>): {
|
|
1035
|
+
cwd?: string;
|
|
1036
|
+
query?: string;
|
|
1037
|
+
limit?: number;
|
|
1038
|
+
} {
|
|
1039
|
+
const result: { cwd?: string; query?: string; limit?: number } = {};
|
|
1040
|
+
if (params.cwd !== undefined) result.cwd = nonempty(params.cwd, "cwd");
|
|
1041
|
+
if (params.query !== undefined) {
|
|
1042
|
+
if (typeof params.query !== "string")
|
|
1043
|
+
throw new Error("query must be a string");
|
|
1044
|
+
const query = params.query.trim();
|
|
1045
|
+
if (query) result.query = query;
|
|
1046
|
+
}
|
|
1047
|
+
if (params.limit !== undefined) {
|
|
1048
|
+
if (
|
|
1049
|
+
typeof params.limit !== "number" ||
|
|
1050
|
+
!Number.isSafeInteger(params.limit) ||
|
|
1051
|
+
params.limit < 1
|
|
1052
|
+
)
|
|
1053
|
+
throw new Error("limit must be a positive integer");
|
|
1054
|
+
result.limit = params.limit;
|
|
1055
|
+
}
|
|
1056
|
+
return result;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
974
1059
|
function validateOpen(params: Record<string, unknown>): SessionOpenParams {
|
|
975
1060
|
const result: SessionOpenParams = { cwd: nonempty(params.cwd, "cwd") };
|
|
976
1061
|
if (params.sessionId !== undefined)
|
package/server/provider.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { slashLine } from "../shared/bridge-protocol.js";
|
|
|
27
27
|
import {
|
|
28
28
|
DEFAULT_PROFILE,
|
|
29
29
|
describeDshLaunch,
|
|
30
|
+
LIST_SESSIONS_TIMEOUT_MS,
|
|
30
31
|
resolveDshLaunch,
|
|
31
32
|
type BridgeMcpServer,
|
|
32
33
|
type DshBridge,
|
|
@@ -404,8 +405,18 @@ async function listSessions(
|
|
|
404
405
|
input: Extract<ProviderInput, { type: "sessions" }>,
|
|
405
406
|
state: DispatchState,
|
|
406
407
|
): Promise<void> {
|
|
407
|
-
const result = await state.discoveryBridge.request("session.list", {});
|
|
408
408
|
const query = input.query?.trim().toLowerCase();
|
|
409
|
+
// Filtering and truncation run in the bridge so title folds only cover the
|
|
410
|
+
// rows Paseo will actually show; the local filters stay as a second net.
|
|
411
|
+
const result = await state.discoveryBridge.request(
|
|
412
|
+
"session.list",
|
|
413
|
+
{
|
|
414
|
+
...(input.cwd ? { cwd: input.cwd } : {}),
|
|
415
|
+
...(query ? { query } : {}),
|
|
416
|
+
...(input.limit !== undefined ? { limit: input.limit } : {}),
|
|
417
|
+
},
|
|
418
|
+
LIST_SESSIONS_TIMEOUT_MS,
|
|
419
|
+
);
|
|
409
420
|
const rows = result.sessions
|
|
410
421
|
.filter((session): session is typeof session & { cwd: string } => Boolean(session.cwd))
|
|
411
422
|
.filter((session) => !input.cwd || session.cwd === input.cwd)
|
package/server/timeline.ts
CHANGED
|
@@ -35,6 +35,8 @@ export class TimelineProjector {
|
|
|
35
35
|
private planActive = false;
|
|
36
36
|
private lastPreset: string | undefined;
|
|
37
37
|
private readonly goals = new Map<string, { objective?: string; maxRounds?: number }>();
|
|
38
|
+
/** Ids of `/goal` commands between their `command/run` and `command/done`; dsh prints their result itself. */
|
|
39
|
+
private readonly goalCommands = new Set<string>();
|
|
38
40
|
|
|
39
41
|
/**
|
|
40
42
|
* @param paseoSessionId Paseo session id stamped on every projected event.
|
|
@@ -59,6 +61,8 @@ export class TimelineProjector {
|
|
|
59
61
|
options: { clientMessageId?: string } = {},
|
|
60
62
|
): SessionEvent[] {
|
|
61
63
|
const timestamp = toTimestamp(event.time);
|
|
64
|
+
// A `/goal` command whose `command/done` never arrived is over once any of these later events appears.
|
|
65
|
+
if (["command/run", "user/message", "turn/start", "turn/end"].includes(event.type)) this.goalCommands.clear();
|
|
62
66
|
switch (event.type) {
|
|
63
67
|
case "user/message":
|
|
64
68
|
return [
|
|
@@ -149,6 +153,7 @@ export class TimelineProjector {
|
|
|
149
153
|
const commandId = stringValue(data.commandId);
|
|
150
154
|
const name = stringValue(data.name);
|
|
151
155
|
if (!commandId || !name) return [];
|
|
156
|
+
if (name === "goal") this.goalCommands.add(commandId);
|
|
152
157
|
const id = `command:${commandId}`;
|
|
153
158
|
// `args` keeps DSH's raw separator whitespace after the command name.
|
|
154
159
|
const text = `/${name}${stringValue(data.args) ?? ""}`;
|
|
@@ -165,6 +170,7 @@ export class TimelineProjector {
|
|
|
165
170
|
const data = event.data;
|
|
166
171
|
const commandId = stringValue(data.commandId);
|
|
167
172
|
const message = stringValue(data.text);
|
|
173
|
+
if (commandId) this.goalCommands.delete(commandId);
|
|
168
174
|
if (!commandId || !message) return [];
|
|
169
175
|
return [
|
|
170
176
|
this.notice(`command-done:${commandId}`, data.kind === "error" ? "error" : "info", message, timestamp),
|
|
@@ -258,7 +264,8 @@ export class TimelineProjector {
|
|
|
258
264
|
}
|
|
259
265
|
} else if (event.type === "goal/change") {
|
|
260
266
|
const card = this.goalChangeCard(data);
|
|
261
|
-
|
|
267
|
+
// A change made while a `/goal` command runs is already reported by that command's own output.
|
|
268
|
+
if (card && this.goalCommands.size === 0) cards.push({ id: `goal:${event.seq}`, kind: CARD_KINDS.goal, data: { ...card } });
|
|
262
269
|
} else if (event.type === "user/message") {
|
|
263
270
|
const source = objectValue(data.source);
|
|
264
271
|
const round = numberValue(source?.round);
|
|
@@ -160,7 +160,10 @@ export interface CommandResult {
|
|
|
160
160
|
}
|
|
161
161
|
export interface BridgeMethods {
|
|
162
162
|
"bridge.initialize": { params: {}; result: BridgeInitializeResult };
|
|
163
|
-
"session.list": {
|
|
163
|
+
"session.list": {
|
|
164
|
+
params: { cwd?: string; query?: string; limit?: number };
|
|
165
|
+
result: { sessions: SessionSummary[] };
|
|
166
|
+
};
|
|
164
167
|
"session.read": { params: { sessionId: string }; result: SessionSnapshot };
|
|
165
168
|
"session.open": { params: SessionOpenParams; result: SessionOpenResult };
|
|
166
169
|
"session.prompt": {
|