paseo-dsh-direct 0.1.4

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.
@@ -0,0 +1,487 @@
1
+ import type {
2
+ ProviderEvent,
3
+ ProviderTimelineItem,
4
+ ProviderToolCallDetail,
5
+ } from "@getpaseo/plugin/server/provider";
6
+ import type { DshSessionEvent, DshStreamFrame, JsonObject } from "../shared/bridge-protocol.js";
7
+ import { attachmentLabel } from "./prompt-content.js";
8
+ import { classifyTurnEnd } from "./turn-end.js";
9
+ import { arrayValue, numberValue, objectValue, stringValue } from "./values.js";
10
+
11
+ export type SessionEvent = Extract<ProviderEvent, { sessionId: string }>;
12
+
13
+ /** Converts DSH durable events and transient stream frames into Paseo cumulative snapshots. */
14
+ export class TimelineProjector {
15
+ private readonly text = new Map<string, string>();
16
+ private readonly compactions = new Map<string, { trigger: "auto" | "manual"; preTokens?: number }>();
17
+ private readonly tools = new Map<string, Extract<ProviderTimelineItem, { type: "tool_call" }>>();
18
+ private readonly settledAttempts = new Map<string, number>();
19
+ private readonly settlementOrdinals = new Map<number, number>();
20
+ private readonly liveAttempts = new Map<
21
+ string,
22
+ { turn: number; step: number; ordinal: number }
23
+ >();
24
+ private readonly liveAttemptByStep = new Map<
25
+ string,
26
+ { attemptId: string; ordinal: number }
27
+ >();
28
+
29
+ constructor(private readonly paseoSessionId: string) {}
30
+
31
+ projectEvent(
32
+ event: DshSessionEvent,
33
+ options: { clientMessageId?: string } = {},
34
+ ): SessionEvent[] {
35
+ const timestamp = toTimestamp(event.time);
36
+ switch (event.type) {
37
+ case "user/message":
38
+ return this.projectUserMessage(event, timestamp);
39
+ case "command/run":
40
+ return this.projectCommandRun(event, options.clientMessageId, timestamp);
41
+ case "command/done":
42
+ return this.projectCommandDone(event, timestamp);
43
+ case "turn/end":
44
+ return this.projectTurnEnd(event, timestamp);
45
+ case "compaction/start":
46
+ case "compaction/summary":
47
+ case "compaction/end":
48
+ return this.projectCompaction(event, timestamp);
49
+ case "assistant/message":
50
+ case "assistant/attempt":
51
+ return this.projectAssistantMessage(event, timestamp);
52
+ case "tool/call":
53
+ return this.projectToolCall(event, timestamp);
54
+ case "tool/result":
55
+ return this.projectToolResult(event, timestamp);
56
+ default:
57
+ return [];
58
+ }
59
+ }
60
+
61
+ projectStream(frame: DshStreamFrame): SessionEvent[] {
62
+ if (frame.type === "start") {
63
+ const key = stepKey(frame.turn, frame.step);
64
+ const ordinal = this.settledAttempts.get(key) ?? 0;
65
+ this.liveAttempts.set(frame.attemptId, { turn: frame.turn, step: frame.step, ordinal });
66
+ this.liveAttemptByStep.set(key, { attemptId: frame.attemptId, ordinal });
67
+ return [];
68
+ }
69
+ if (frame.type !== "chunk") return [];
70
+ const chunk = frame.chunk;
71
+ const kind = stringValue(chunk.type);
72
+ const index = numberValue(chunk.index) ?? frame.index;
73
+ if (kind === "text-delta" || kind === "reasoning-delta") {
74
+ const type = kind === "text-delta" ? "assistant_message" : "reasoning";
75
+ const attempt = this.liveAttempts.get(frame.attemptId);
76
+ const id = attempt
77
+ ? assistantItemId(type, attempt.turn, attempt.step, attempt.ordinal, index)
78
+ : transientItemId(type, frame.attemptId, index);
79
+ const cumulative = (this.text.get(id) ?? "") + (stringValue(chunk.text) ?? "");
80
+ this.text.set(id, cumulative);
81
+ return [timeline(this.paseoSessionId, { type, id, text: cumulative }, toTimestamp(frame.time))];
82
+ }
83
+ if (kind === "tool-call-delta") {
84
+ const callId = stringValue(chunk.id) ?? `tool:${frame.attemptId}:${index}`;
85
+ const existing = this.tools.get(callId);
86
+ const argumentDelta = stringValue(chunk.argumentsDelta) ?? "";
87
+ const argumentsText =
88
+ (existing?.metadata && stringValue(existing.metadata.argumentsText)) ?? "";
89
+ const name = stringValue(chunk.name) ?? existing?.name ?? "tool";
90
+ const item: Extract<ProviderTimelineItem, { type: "tool_call" }> = {
91
+ type: "tool_call",
92
+ id: `tool:${callId}`,
93
+ callId,
94
+ name,
95
+ status: "running",
96
+ error: null,
97
+ detail: toolDetail(name, parseArguments(argumentsText + argumentDelta)),
98
+ metadata: { argumentsText: argumentsText + argumentDelta },
99
+ };
100
+ this.tools.set(callId, item);
101
+ return [timeline(this.paseoSessionId, item, toTimestamp(frame.time))];
102
+ }
103
+ return [];
104
+ }
105
+
106
+ failRunningTools(message: string): SessionEvent[] {
107
+ return this.settleRunningTools(message);
108
+ }
109
+
110
+ private projectCommandRun(
111
+ event: DshSessionEvent,
112
+ clientMessageId: string | undefined,
113
+ timestamp?: string,
114
+ ): SessionEvent[] {
115
+ const data = event.data;
116
+ const commandId = stringValue(data.commandId);
117
+ const name = stringValue(data.name);
118
+ if (!commandId || !name) return [];
119
+ const id = `command:${commandId}`;
120
+ // `args` keeps DSH's raw separator whitespace after the command name.
121
+ const text = `/${name}${stringValue(data.args) ?? ""}`;
122
+ return [
123
+ timeline(
124
+ this.paseoSessionId,
125
+ { type: "user_message", id, messageId: id, text, ...(clientMessageId ? { clientMessageId } : {}) },
126
+ timestamp,
127
+ ),
128
+ ];
129
+ }
130
+
131
+ private projectCommandDone(event: DshSessionEvent, timestamp?: string): SessionEvent[] {
132
+ const data = event.data;
133
+ const commandId = stringValue(data.commandId);
134
+ const message = stringValue(data.text);
135
+ if (!commandId || !message) return [];
136
+ return [
137
+ this.notice(`command-done:${commandId}`, data.kind === "error" ? "error" : "info", message, timestamp),
138
+ ];
139
+ }
140
+
141
+ /** Failures (replay only; live failures travel on session.turn) and abnormal ends. */
142
+ private projectTurnEnd(event: DshSessionEvent, timestamp?: string): SessionEvent[] {
143
+ const terminal = classifyTurnEnd(event.data);
144
+ const projected: SessionEvent[] = [];
145
+ if (terminal.warning) projected.push(this.notice(`turn-end:${event.seq}`, "warning", terminal.warning, timestamp));
146
+ if (terminal.error) {
147
+ projected.push(
148
+ timeline(this.paseoSessionId, { type: "error", id: `turn-error:${event.seq}`, message: terminal.error.message }, timestamp),
149
+ );
150
+ }
151
+ // A turn never emits further tool results: settle whatever is still running
152
+ // so interrupted turns do not leave permanently spinning tool cards.
153
+ projected.push(...this.settleRunningTools(terminal.error?.message ?? terminal.warning ?? "Turn ended", timestamp));
154
+ return projected;
155
+ }
156
+
157
+ /** Marks every running tool card failed; used at turn end and bridge failure. */
158
+ private settleRunningTools(message: string, timestamp?: string): SessionEvent[] {
159
+ const events: SessionEvent[] = [];
160
+ for (const [callId, item] of this.tools) {
161
+ if (item.status !== "running") continue;
162
+ const failed: Extract<ProviderTimelineItem, { type: "tool_call" }> = {
163
+ ...item,
164
+ status: "failed",
165
+ error: message,
166
+ };
167
+ this.tools.set(callId, failed);
168
+ events.push(timeline(this.paseoSessionId, failed, timestamp));
169
+ }
170
+ return events;
171
+ }
172
+
173
+ private notice(
174
+ id: string,
175
+ level: "info" | "warning" | "error",
176
+ message: string,
177
+ timestamp?: string,
178
+ ): SessionEvent {
179
+ return timeline(this.paseoSessionId, { type: "notification", id, level, message }, timestamp);
180
+ }
181
+
182
+ private projectCompaction(event: DshSessionEvent, timestamp?: string): SessionEvent[] {
183
+ const compactionId = stringValue(event.data.compactionId);
184
+ if (!compactionId) return [];
185
+ const id = `compaction:${compactionId}`;
186
+ const known = this.compactions.get(compactionId);
187
+ const trigger = known?.trigger ?? (stringValue(event.data.sourceCommandId) ? "manual" : "auto");
188
+ if (event.type === "compaction/start") {
189
+ this.compactions.set(compactionId, { trigger });
190
+ return [timeline(this.paseoSessionId, { type: "compaction", id, status: "loading", trigger }, timestamp)];
191
+ }
192
+ if (event.type === "compaction/summary") {
193
+ const tokens = numberValue(event.data.shadowedTokenCount);
194
+ if (known && tokens !== undefined) known.preTokens = tokens;
195
+ return [];
196
+ }
197
+ this.compactions.delete(compactionId);
198
+ const preTokens = known?.preTokens;
199
+ const projected = [
200
+ timeline(
201
+ this.paseoSessionId,
202
+ { type: "compaction", id, status: "completed", trigger, ...(preTokens !== undefined ? { preTokens } : {}) },
203
+ timestamp,
204
+ ),
205
+ ];
206
+ const error = stringValue(event.data.error);
207
+ if (error) projected.push(this.notice(`${id}:error`, "error", `Compaction failed: ${error}`, timestamp));
208
+ return projected;
209
+ }
210
+
211
+ private projectUserMessage(event: DshSessionEvent, timestamp?: string): SessionEvent[] {
212
+ const data = event.data;
213
+ const source = objectValue(data.source);
214
+ if (source?.kind !== "user") return [];
215
+ const id = stringValue(data.id) ?? `event:${event.seq}`;
216
+ const text = userContentText(data.content);
217
+ if (!text) return [];
218
+ return [timeline(this.paseoSessionId, { type: "user_message", id, messageId: id, text }, timestamp)];
219
+ }
220
+
221
+ private projectAssistantMessage(event: DshSessionEvent, timestamp?: string): SessionEvent[] {
222
+ const data = event.data;
223
+ const message = objectValue(data.message) ?? data;
224
+ const messageId = stringValue(message.id) ?? `event:${event.seq}`;
225
+ const turn = numberValue(data.turn) ?? 0;
226
+ const step = numberValue(data.step) ?? 0;
227
+ const ordinal = this.settlementOrdinal(event.seq, turn, step);
228
+ const content = arrayValue(message.content);
229
+ const projected: SessionEvent[] = [];
230
+ content.forEach((raw, index) => {
231
+ const block = objectValue(raw);
232
+ if (!block) return;
233
+ const type = stringValue(block.type);
234
+ if (type === "text" || type === "reasoning") {
235
+ const itemType = type === "text" ? "assistant_message" : "reasoning";
236
+ const id = assistantItemId(itemType, turn, step, ordinal, index);
237
+ const text = stringValue(block.text) ?? "";
238
+ this.text.set(id, text);
239
+ projected.push(
240
+ timeline(
241
+ this.paseoSessionId,
242
+ itemType === "assistant_message"
243
+ ? { type: itemType, id, messageId, text }
244
+ : { type: itemType, id, text },
245
+ timestamp,
246
+ ),
247
+ );
248
+ } else if (type === "tool-call") {
249
+ const callId = stringValue(block.id) ?? stringValue(block.toolCallId) ?? `${messageId}:${index}`;
250
+ const name = stringValue(block.name) ?? "tool";
251
+ const args = parseArguments(block.arguments);
252
+ const item: Extract<ProviderTimelineItem, { type: "tool_call" }> = {
253
+ type: "tool_call",
254
+ id: `tool:${callId}`,
255
+ callId,
256
+ name,
257
+ status: "running",
258
+ error: null,
259
+ detail: toolDetail(name, args),
260
+ metadata: { argumentsText: stringifyValue(block.arguments) },
261
+ };
262
+ this.tools.set(callId, item);
263
+ projected.push(timeline(this.paseoSessionId, item, timestamp));
264
+ }
265
+ });
266
+ const usage = objectValue(data.usage) ?? objectValue(message.usage);
267
+ if (usage) {
268
+ projected.push({
269
+ type: "session.usage",
270
+ sessionId: this.paseoSessionId,
271
+ usage: {
272
+ inputTokens: numberValue(usage.inputTokens) ?? numberValue(usage.input_tokens),
273
+ cachedInputTokens:
274
+ numberValue(usage.cachedInputTokens) ?? numberValue(usage.cached_input_tokens),
275
+ outputTokens: numberValue(usage.outputTokens) ?? numberValue(usage.output_tokens),
276
+ totalCostUsd: numberValue(usage.totalCostUsd) ?? numberValue(usage.total_cost_usd),
277
+ },
278
+ });
279
+ }
280
+ return projected;
281
+ }
282
+
283
+ private settlementOrdinal(seq: number, turn: number, step: number): number {
284
+ const known = this.settlementOrdinals.get(seq);
285
+ if (known !== undefined) return known;
286
+
287
+ const key = stepKey(turn, step);
288
+ const live = this.liveAttemptByStep.get(key);
289
+ const ordinal = live?.ordinal ?? this.settledAttempts.get(key) ?? 0;
290
+ this.settlementOrdinals.set(seq, ordinal);
291
+ this.settledAttempts.set(key, Math.max(this.settledAttempts.get(key) ?? 0, ordinal + 1));
292
+ if (live) {
293
+ this.liveAttemptByStep.delete(key);
294
+ this.liveAttempts.delete(live.attemptId);
295
+ }
296
+ return ordinal;
297
+ }
298
+
299
+ private projectToolCall(event: DshSessionEvent, timestamp?: string): SessionEvent[] {
300
+ const callId = stringValue(event.data.callId) ?? `event:${event.seq}`;
301
+ const name = stringValue(event.data.name) ?? "tool";
302
+ const args = parseArguments(event.data.arguments);
303
+ const item: Extract<ProviderTimelineItem, { type: "tool_call" }> = {
304
+ type: "tool_call",
305
+ id: `tool:${callId}`,
306
+ callId,
307
+ name,
308
+ status: "running",
309
+ error: null,
310
+ detail: toolDetail(name, args),
311
+ metadata: { argumentsText: stringifyValue(event.data.arguments) },
312
+ };
313
+ this.tools.set(callId, item);
314
+ return [timeline(this.paseoSessionId, item, timestamp)];
315
+ }
316
+
317
+ private projectToolResult(event: DshSessionEvent, timestamp?: string): SessionEvent[] {
318
+ const message = objectValue(event.data.message) ?? event.data;
319
+ // DSH tool messages carry the call id next to the content (`toolCallId`,
320
+ // `source.callId`) with plain text blocks inside; a `tool-result` content
321
+ // block is accepted for layouts that embed it instead.
322
+ const direct = objectValue(message.content);
323
+ const block = direct?.type === "tool-result"
324
+ ? direct
325
+ : arrayValue(message.content)
326
+ .map(objectValue)
327
+ .find((candidate) => candidate?.type === "tool-result");
328
+ const source = objectValue(message.source);
329
+ const callId =
330
+ stringValue(message.toolCallId) ??
331
+ stringValue(message.tool_call_id) ??
332
+ (source?.kind === "tool" ? stringValue(source.callId) : undefined) ??
333
+ stringValue(block?.toolCallId) ??
334
+ stringValue(block?.tool_call_id);
335
+ if (!callId) return [];
336
+ const existing = this.tools.get(callId) ?? {
337
+ type: "tool_call" as const,
338
+ id: `tool:${callId}`,
339
+ callId,
340
+ name: "tool",
341
+ status: "running" as const,
342
+ error: null,
343
+ detail: { type: "unknown" as const, input: {}, output: {} },
344
+ };
345
+ const payload = block?.content ?? message.content;
346
+ const output = contentText(payload) || stringifyValue(payload);
347
+ const isError =
348
+ message.isError === true || event.data.isError === true || block?.isError === true;
349
+ const detail = withOutput(existing.detail, output);
350
+ const item: Extract<ProviderTimelineItem, { type: "tool_call" }> = isError
351
+ ? { ...existing, detail, status: "failed", error: output || "Tool failed" }
352
+ : { ...existing, detail, status: "completed", error: null };
353
+ this.tools.set(callId, item);
354
+ return [timeline(this.paseoSessionId, item, timestamp)];
355
+ }
356
+ }
357
+
358
+ function stepKey(turn: number, step: number): string {
359
+ return `${turn}:${step}`;
360
+ }
361
+
362
+ function assistantItemId(
363
+ type: string,
364
+ turn: number,
365
+ step: number,
366
+ ordinal: number,
367
+ index: number,
368
+ ): string {
369
+ return `${type}:turn:${turn}:step:${step}:attempt:${ordinal}:${index}`;
370
+ }
371
+
372
+ function transientItemId(type: string, attemptId: string, index: number): string {
373
+ return `${type}:transient:${attemptId}:${index}`;
374
+ }
375
+
376
+ function timeline(
377
+ sessionId: string,
378
+ item: ProviderTimelineItem,
379
+ timestamp?: string,
380
+ ): SessionEvent {
381
+ return { type: "timeline.item", sessionId, item, ...(timestamp ? { timestamp } : {}) };
382
+ }
383
+
384
+ function toolDetail(name: string, args: JsonObject): ProviderToolCallDetail {
385
+ const normalized = name.toLowerCase();
386
+ if (/shell|bash|exec|command/u.test(normalized)) {
387
+ return {
388
+ type: "shell",
389
+ command: stringValue(args.command) ?? stringValue(args.cmd) ?? stringifyValue(args),
390
+ cwd: stringValue(args.cwd),
391
+ };
392
+ }
393
+ const filePath = stringValue(args.filePath) ?? stringValue(args.path) ?? "";
394
+ if (/read/u.test(normalized)) {
395
+ return { type: "read", filePath, offset: numberValue(args.offset), limit: numberValue(args.limit) };
396
+ }
397
+ if (/edit|patch/u.test(normalized)) {
398
+ return {
399
+ type: "edit",
400
+ filePath,
401
+ oldString: stringValue(args.oldString) ?? stringValue(args.old_string),
402
+ newString: stringValue(args.newString) ?? stringValue(args.new_string),
403
+ unifiedDiff: stringValue(args.patch) ?? stringValue(args.diff),
404
+ };
405
+ }
406
+ if (/write/u.test(normalized)) {
407
+ return { type: "write", filePath, content: stringValue(args.content) };
408
+ }
409
+ if (/search|grep|glob/u.test(normalized)) {
410
+ return { type: "search", query: stringValue(args.query) ?? stringValue(args.pattern) ?? "" };
411
+ }
412
+ return { type: "unknown", input: { raw: stringifyValue(args) }, output: {} };
413
+ }
414
+
415
+ function withOutput(detail: ProviderToolCallDetail, output: string): ProviderToolCallDetail {
416
+ switch (detail.type) {
417
+ case "shell":
418
+ return { ...detail, output };
419
+ case "read":
420
+ return { ...detail, content: output };
421
+ case "search":
422
+ return { ...detail, content: output };
423
+ case "fetch":
424
+ return { ...detail, result: output };
425
+ case "plain_text":
426
+ return { ...detail, text: output };
427
+ case "unknown":
428
+ return { ...detail, output };
429
+ default:
430
+ return detail;
431
+ }
432
+ }
433
+
434
+ function parseArguments(value: unknown): JsonObject {
435
+ if (objectValue(value)) return objectValue(value)!;
436
+ if (typeof value !== "string") return {};
437
+ try {
438
+ return objectValue(JSON.parse(value)) ?? { value };
439
+ } catch {
440
+ return { value };
441
+ }
442
+ }
443
+
444
+ /** User message text with markers for image and file blocks. */
445
+ function userContentText(value: unknown): string {
446
+ if (typeof value === "string") return value;
447
+ return arrayValue(value)
448
+ .map((raw) => {
449
+ const block = objectValue(raw);
450
+ if (block?.type === "text") return stringValue(block.text) ?? "";
451
+ if (block?.type === "image") return attachmentLabel("image");
452
+ if (block?.type === "file") return attachmentLabel("file", stringValue(objectValue(block.attachment)?.name));
453
+ return "";
454
+ })
455
+ .filter(Boolean)
456
+ .join("\n");
457
+ }
458
+
459
+ function contentText(value: unknown): string {
460
+ if (typeof value === "string") return value;
461
+ return arrayValue(value)
462
+ .map((raw) => {
463
+ const block = objectValue(raw);
464
+ if (!block) return "";
465
+ if (typeof block.text === "string") return block.text;
466
+ if (typeof block.content === "string") return block.content;
467
+ return "";
468
+ })
469
+ .filter(Boolean)
470
+ .join("\n");
471
+ }
472
+
473
+ function stringifyValue(value: unknown): string {
474
+ if (typeof value === "string") return value;
475
+ try {
476
+ return JSON.stringify(value) ?? "";
477
+ } catch {
478
+ return String(value);
479
+ }
480
+ }
481
+
482
+ function toTimestamp(value: unknown): string | undefined {
483
+ const milliseconds = numberValue(value);
484
+ if (milliseconds === undefined) return undefined;
485
+ const date = new Date(milliseconds < 10_000_000_000 ? milliseconds * 1_000 : milliseconds);
486
+ return Number.isNaN(date.valueOf()) ? undefined : date.toISOString();
487
+ }
@@ -0,0 +1,41 @@
1
+ import type { ProviderError } from "@getpaseo/plugin/server/provider";
2
+ import { numberValue, objectValue, stringValue } from "./values.js";
3
+
4
+ export type TurnState = "completed" | "failed" | "canceled";
5
+ export interface TurnTerminal {
6
+ state: TurnState;
7
+ error?: ProviderError;
8
+ /** Notice for turns that completed abnormally. */
9
+ warning?: string;
10
+ }
11
+
12
+ const WARNINGS: Record<string, string> = {
13
+ "max-tokens": "The model reached its output token limit in this turn; the reply may be incomplete.",
14
+ blocked: "A DSH plugin blocked this turn before the model ran.",
15
+ interrupted: "This turn did not finish: the DSH process stopped before it ended.",
16
+ };
17
+
18
+ /** Classify DSH `turn/end` data; failures carry DSH's LlmFailure (message, code, status, request id). */
19
+ export function classifyTurnEnd(data: Record<string, unknown>): TurnTerminal {
20
+ const reason = objectValue(data.reason);
21
+ const kind = stringValue(reason?.kind) ?? "";
22
+ if (kind === "aborted" || kind === "canceled" || kind === "cancelled") return { state: "canceled" };
23
+ if (kind === "error" || kind === "failed") return { state: "failed", error: failure(objectValue(reason?.error)) };
24
+ return WARNINGS[kind] ? { state: "completed", warning: WARNINGS[kind] } : { state: "completed" };
25
+ }
26
+
27
+ function failure(error: Record<string, unknown> | undefined): ProviderError {
28
+ const message = stringValue(error?.message)?.trim() ? String(error?.message) : "DSH turn failed";
29
+ const code = stringValue(error?.code);
30
+ const status = numberValue(error?.status);
31
+ const requestId = stringValue(error?.requestId);
32
+ const diagnostic = [
33
+ status !== undefined ? `HTTP ${status}` : undefined,
34
+ requestId ? `request ${requestId}` : undefined,
35
+ ].filter(Boolean).join(", ");
36
+ return {
37
+ message: code ? `[${code}] ${message}` : message,
38
+ ...(code ? { code } : {}),
39
+ ...(diagnostic ? { diagnostic } : {}),
40
+ };
41
+ }
@@ -0,0 +1,20 @@
1
+ import type { JsonObject } from "../shared/bridge-protocol.js";
2
+
3
+ /** Narrow untyped DSH event data without trusting its shape. */
4
+ export function objectValue(value: unknown): JsonObject | undefined {
5
+ return typeof value === "object" && value !== null && !Array.isArray(value)
6
+ ? (value as JsonObject)
7
+ : undefined;
8
+ }
9
+
10
+ export function arrayValue(value: unknown): unknown[] {
11
+ return Array.isArray(value) ? value : [];
12
+ }
13
+
14
+ export function stringValue(value: unknown): string | undefined {
15
+ return typeof value === "string" ? value : undefined;
16
+ }
17
+
18
+ export function numberValue(value: unknown): number | undefined {
19
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
20
+ }
@@ -0,0 +1,50 @@
1
+ /** Compatibility classification between this plugin's version and a detected dsh version. */
2
+ export type VersionLineMatch = "match" | "mismatch" | "unknown";
3
+
4
+ /**
5
+ * The major.minor line a version string belongs to (e.g. "0.1"), ignoring
6
+ * patch and any prerelease suffix (`-rc.N`, `-alpha.N`). Returns undefined
7
+ * when no leading `major.minor` can be read from the string.
8
+ */
9
+ export function versionLine(version: string): string | undefined {
10
+ const match = /^\s*v?(\d+)\.(\d+)(?:\.\d+)?/i.exec(version);
11
+ return match ? `${match[1]}.${match[2]}` : undefined;
12
+ }
13
+
14
+ /**
15
+ * Whether `pluginVersion` and `dshVersion` belong to the same major.minor
16
+ * line: the plugin's `0.1.x` line tracks dsh `0.1.*`. A dsh prerelease
17
+ * suffix does not affect its line. Either string failing to parse a
18
+ * `major.minor` (blank, garbled output) yields "unknown", not "mismatch" —
19
+ * this plugin only warns, it never blocks on an unconfirmed version.
20
+ */
21
+ export function matchVersionLine(pluginVersion: string, dshVersion: string): VersionLineMatch {
22
+ const pluginLine = versionLine(pluginVersion);
23
+ const dshLine = versionLine(dshVersion);
24
+ if (!pluginLine || !dshLine) return "unknown";
25
+ return pluginLine === dshLine ? "match" : "mismatch";
26
+ }
27
+
28
+ /**
29
+ * One-line warning for a "mismatch" or "unknown" classification: states the
30
+ * plugin's supported dsh line, the detected dsh version, and what to do.
31
+ * Never called for "match".
32
+ */
33
+ export function versionWarningMessage(
34
+ status: Exclude<VersionLineMatch, "match">,
35
+ pluginVersion: string,
36
+ dshVersion: string,
37
+ ): string {
38
+ const pluginLine = versionLine(pluginVersion) ?? pluginVersion;
39
+ if (status === "unknown") {
40
+ return (
41
+ `Could not confirm the local dsh version (this plugin supports dsh ${pluginLine}.*). ` +
42
+ `The session started anyway; check that dsh is installed and "dsh --version" reports a version.`
43
+ );
44
+ }
45
+ const dshLine = versionLine(dshVersion) ?? dshVersion;
46
+ return (
47
+ `This plugin supports dsh ${pluginLine}.*; detected dsh ${dshVersion}. ` +
48
+ `Install a plugin tag for the dsh ${dshLine}.x line, or switch the dsh executable to a ${pluginLine}.x build.`
49
+ );
50
+ }