@oh-my-pi/pi-coding-agent 15.1.7 → 15.1.8
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
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.1.8] - 2026-05-20
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed streaming edit previews for `apply_patch` and `hashline` jittering as the model typed `+added` lines. Two root causes addressed: (1) the trailing partial line of the streaming text input is now trimmed at each tick so a half-typed `+added` line no longer flickers; (2) the preview is rendered in the model's input order during streaming instead of re-deriving a unified diff via `Diff.structuredPatch`, whose coalescing previously reshuffled existing `+added` lines downward each time a new `-removed` line arrived. Existing additions now stay put and the preview only grows at the bottom while streaming. A residual trailing `-removed`/hunk-header block whose matching `+added` companion has not yet arrived is also suppressed until the additions land.
|
|
10
|
+
- Fixed Perplexity web search appearing "logged out" roughly an hour after `omp auth login perplexity`. The search provider's `findOAuthToken` was honoring the bogus `expires = login_time + 1h` written by older logins (Perplexity JWTs typically omit `exp` because sessions are server-side) and silently dropping the credential. The loader now decodes the JWT's `exp` claim directly and only skips when the JWT itself is expired; tokens without an `exp` claim are treated as non-expiring.
|
|
11
|
+
|
|
5
12
|
## [15.1.7] - 2026-05-19
|
|
6
13
|
|
|
7
14
|
### Fixed
|
|
@@ -26,6 +26,13 @@ export interface StreamingDiffContext {
|
|
|
26
26
|
fuzzyThreshold?: number;
|
|
27
27
|
allowFuzzy?: boolean;
|
|
28
28
|
hashlineAutoDropPureInsertDuplicates?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* True while the tool's arguments are still streaming in. Strategies that
|
|
31
|
+
* accept free-form text input (apply_patch, hashline) trim the trailing
|
|
32
|
+
* partial line so per-character growth of an in-flight `+added` line does
|
|
33
|
+
* not flicker in the preview.
|
|
34
|
+
*/
|
|
35
|
+
isStreaming?: boolean;
|
|
29
36
|
}
|
|
30
37
|
export interface EditStreamingStrategy<Args = unknown> {
|
|
31
38
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "15.1.
|
|
4
|
+
"version": "15.1.8",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -47,12 +47,12 @@
|
|
|
47
47
|
"@agentclientprotocol/sdk": "0.21.0",
|
|
48
48
|
"@babel/parser": "^7.29.3",
|
|
49
49
|
"@mozilla/readability": "^0.6.0",
|
|
50
|
-
"@oh-my-pi/omp-stats": "15.1.
|
|
51
|
-
"@oh-my-pi/pi-agent-core": "15.1.
|
|
52
|
-
"@oh-my-pi/pi-ai": "15.1.
|
|
53
|
-
"@oh-my-pi/pi-natives": "15.1.
|
|
54
|
-
"@oh-my-pi/pi-tui": "15.1.
|
|
55
|
-
"@oh-my-pi/pi-utils": "15.1.
|
|
50
|
+
"@oh-my-pi/omp-stats": "15.1.8",
|
|
51
|
+
"@oh-my-pi/pi-agent-core": "15.1.8",
|
|
52
|
+
"@oh-my-pi/pi-ai": "15.1.8",
|
|
53
|
+
"@oh-my-pi/pi-natives": "15.1.8",
|
|
54
|
+
"@oh-my-pi/pi-tui": "15.1.8",
|
|
55
|
+
"@oh-my-pi/pi-utils": "15.1.8",
|
|
56
56
|
"@puppeteer/browsers": "^2.13.0",
|
|
57
57
|
"@types/turndown": "5.0.6",
|
|
58
58
|
"@xterm/headless": "^6.0.0",
|
package/src/edit/streaming.ts
CHANGED
|
@@ -45,6 +45,13 @@ export interface StreamingDiffContext {
|
|
|
45
45
|
fuzzyThreshold?: number;
|
|
46
46
|
allowFuzzy?: boolean;
|
|
47
47
|
hashlineAutoDropPureInsertDuplicates?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* True while the tool's arguments are still streaming in. Strategies that
|
|
50
|
+
* accept free-form text input (apply_patch, hashline) trim the trailing
|
|
51
|
+
* partial line so per-character growth of an in-flight `+added` line does
|
|
52
|
+
* not flicker in the preview.
|
|
53
|
+
*/
|
|
54
|
+
isStreaming?: boolean;
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
export interface EditStreamingStrategy<Args = unknown> {
|
|
@@ -274,21 +281,146 @@ interface HashlineArgs {
|
|
|
274
281
|
__partialJson?: string;
|
|
275
282
|
}
|
|
276
283
|
|
|
284
|
+
/**
|
|
285
|
+
* While streaming a free-form text payload (apply_patch envelope, hashline
|
|
286
|
+
* input), trim the trailing partial line so per-character growth of an
|
|
287
|
+
* in-flight `+added` line does not cause the diff preview to flicker. The
|
|
288
|
+
* full line will show on the next streaming tick once its `\n` arrives.
|
|
289
|
+
* Returns `text` unchanged when not streaming or when no newline is present.
|
|
290
|
+
*/
|
|
291
|
+
function trimTrailingPartialLine(text: string, isStreaming: boolean | undefined): string {
|
|
292
|
+
if (!isStreaming) return text;
|
|
293
|
+
const idx = text.lastIndexOf("\n");
|
|
294
|
+
if (idx === -1) return "";
|
|
295
|
+
return text.slice(0, idx + 1);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Build a per-file diff preview directly from a partial `apply_patch`
|
|
300
|
+
* envelope by emitting its body lines in *input order*. This bypasses the
|
|
301
|
+
* file-state re-diff (`computePatchDiff` → `Diff.structuredPatch`) whose
|
|
302
|
+
* coalescing reorders the model's `-old +new -old +new` stream into
|
|
303
|
+
* `-old -old +new +new` and visibly shifts existing `+added` lines
|
|
304
|
+
* downward each time a new `-` arrives. The preview therefore grows
|
|
305
|
+
* monotonically at the bottom while streaming and only becomes a real
|
|
306
|
+
* unified diff once the args are complete.
|
|
307
|
+
*/
|
|
308
|
+
function buildApplyPatchNaturalOrderPreviews(input: string): PerFileDiffPreview[] | null {
|
|
309
|
+
const lines = input.split("\n");
|
|
310
|
+
const groups = new Map<string, string[]>();
|
|
311
|
+
let currentPath: string | undefined;
|
|
312
|
+
const ensure = (path: string): string[] => {
|
|
313
|
+
let bucket = groups.get(path);
|
|
314
|
+
if (!bucket) {
|
|
315
|
+
bucket = [];
|
|
316
|
+
groups.set(path, bucket);
|
|
317
|
+
}
|
|
318
|
+
return bucket;
|
|
319
|
+
};
|
|
320
|
+
for (const raw of lines) {
|
|
321
|
+
const trimmedEnd = raw.trimEnd();
|
|
322
|
+
if (trimmedEnd === BEGIN_PATCH_MARKER || trimmedEnd === END_PATCH_MARKER || trimmedEnd === ABORT_MARKER) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (trimmedEnd.startsWith("*** Add File: ")) {
|
|
326
|
+
currentPath = trimmedEnd.slice("*** Add File: ".length);
|
|
327
|
+
ensure(currentPath);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (trimmedEnd.startsWith("*** Delete File: ")) {
|
|
331
|
+
currentPath = trimmedEnd.slice("*** Delete File: ".length);
|
|
332
|
+
ensure(currentPath);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (trimmedEnd.startsWith("*** Update File: ")) {
|
|
336
|
+
currentPath = trimmedEnd.slice("*** Update File: ".length);
|
|
337
|
+
ensure(currentPath);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (trimmedEnd.startsWith("*** Move to:") || trimmedEnd.startsWith("*** End of File")) {
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (!currentPath) continue;
|
|
344
|
+
// Diff body: keep `-/+/space`-prefixed lines and `@@` hunk headers in
|
|
345
|
+
// input order. parseDiffLine accepts the no-line-number legacy form so
|
|
346
|
+
// the renderer styles them as additions/removals/context naturally.
|
|
347
|
+
if (raw.startsWith("+") || raw.startsWith("-") || raw.startsWith(" ") || raw.startsWith("@@")) {
|
|
348
|
+
ensure(currentPath).push(raw);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (groups.size === 0) return null;
|
|
352
|
+
const previews: PerFileDiffPreview[] = [];
|
|
353
|
+
for (const [path, body] of groups) {
|
|
354
|
+
if (body.length === 0) continue;
|
|
355
|
+
previews.push({ path, diff: body.join("\n") });
|
|
356
|
+
}
|
|
357
|
+
return previews.length > 0 ? previews : null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Hashline equivalent: emit each section's `~payload` lines as `+added`
|
|
362
|
+
* lines in the order the model typed them. We deliberately omit op headers
|
|
363
|
+
* and removal targets from the streaming preview because their content
|
|
364
|
+
* lives in the file and would require a costly re-apply per tick; the
|
|
365
|
+
* complete unified diff is shown once streaming finishes.
|
|
366
|
+
*/
|
|
367
|
+
function buildHashlineNaturalOrderPreviews(
|
|
368
|
+
input: string,
|
|
369
|
+
defaultPath: string | undefined,
|
|
370
|
+
): PerFileDiffPreview[] | null {
|
|
371
|
+
const lines = input.split("\n");
|
|
372
|
+
const groups = new Map<string, string[]>();
|
|
373
|
+
let currentPath = defaultPath ?? "";
|
|
374
|
+
const ensure = (path: string): string[] => {
|
|
375
|
+
let bucket = groups.get(path);
|
|
376
|
+
if (!bucket) {
|
|
377
|
+
bucket = [];
|
|
378
|
+
groups.set(path, bucket);
|
|
379
|
+
}
|
|
380
|
+
return bucket;
|
|
381
|
+
};
|
|
382
|
+
for (const raw of lines) {
|
|
383
|
+
if (isHashlineEnvelopeMarkerLine(raw)) continue;
|
|
384
|
+
if (isHashlineHeaderLine(raw)) {
|
|
385
|
+
currentPath = raw.trimEnd().slice(1).trim();
|
|
386
|
+
if (currentPath) ensure(currentPath);
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (raw.startsWith("~")) {
|
|
390
|
+
ensure(currentPath).push(`+${raw.slice(1)}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
if (groups.size === 0) return null;
|
|
394
|
+
const previews: PerFileDiffPreview[] = [];
|
|
395
|
+
for (const [path, body] of groups) {
|
|
396
|
+
if (body.length === 0) continue;
|
|
397
|
+
previews.push({ path, diff: body.join("\n") });
|
|
398
|
+
}
|
|
399
|
+
return previews.length > 0 ? previews : null;
|
|
400
|
+
}
|
|
401
|
+
|
|
277
402
|
const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
|
|
278
403
|
extractCompleteEdits(args) {
|
|
279
404
|
return args;
|
|
280
405
|
},
|
|
281
406
|
async computeDiffPreview(args, ctx) {
|
|
282
407
|
if (typeof args.input !== "string" || args.input.length === 0) return null;
|
|
408
|
+
const input = trimTrailingPartialLine(args.input, ctx.isStreaming);
|
|
409
|
+
if (input.length === 0) return null;
|
|
410
|
+
if (ctx.isStreaming) {
|
|
411
|
+
// Skip the costly per-tick re-apply and avoid `Diff.structuredPatch`
|
|
412
|
+
// reordering by showing the model's `~payload` lines in input order.
|
|
413
|
+
return buildHashlineNaturalOrderPreviews(input, args.path);
|
|
414
|
+
}
|
|
283
415
|
ctx.signal.throwIfAborted();
|
|
284
416
|
|
|
285
417
|
let sections: HashlineInputSection[];
|
|
286
418
|
try {
|
|
287
|
-
sections = splitHashlineInputs(
|
|
419
|
+
sections = splitHashlineInputs(input, { cwd: ctx.cwd, path: args.path });
|
|
288
420
|
} catch {
|
|
289
421
|
// Single-section fallback keeps the original error rendering for the
|
|
290
422
|
// "haven't typed `@@ PATH` yet" case.
|
|
291
|
-
const result = await computeHashlineDiff({ input
|
|
423
|
+
const result = await computeHashlineDiff({ input, path: args.path }, ctx.cwd, {
|
|
292
424
|
autoDropPureInsertDuplicates: ctx.hashlineAutoDropPureInsertDuplicates,
|
|
293
425
|
});
|
|
294
426
|
ctx.signal.throwIfAborted();
|
|
@@ -340,12 +472,21 @@ const applyPatchStrategy: EditStreamingStrategy<ApplyPatchArgs> = {
|
|
|
340
472
|
},
|
|
341
473
|
async computeDiffPreview(args, ctx) {
|
|
342
474
|
if (typeof args.input !== "string" || args.input.length === 0) return null;
|
|
475
|
+
const input = trimTrailingPartialLine(args.input, ctx.isStreaming);
|
|
476
|
+
if (input.length === 0) return null;
|
|
477
|
+
if (ctx.isStreaming) {
|
|
478
|
+
// Render the envelope's diff body in input order so newly streamed
|
|
479
|
+
// `+added` lines append at the bottom instead of being shuffled
|
|
480
|
+
// upward as later `-removed` lines arrive and reorder the unified
|
|
481
|
+
// diff that `Diff.structuredPatch` would otherwise produce.
|
|
482
|
+
return buildApplyPatchNaturalOrderPreviews(input);
|
|
483
|
+
}
|
|
343
484
|
let entries: ApplyPatchEntry[];
|
|
344
485
|
try {
|
|
345
|
-
entries = expandApplyPatchToEntries({ input
|
|
486
|
+
entries = expandApplyPatchToEntries({ input });
|
|
346
487
|
} catch {
|
|
347
488
|
try {
|
|
348
|
-
entries = expandApplyPatchToPreviewEntries({ input
|
|
489
|
+
entries = expandApplyPatchToPreviewEntries({ input });
|
|
349
490
|
} catch (err) {
|
|
350
491
|
return [{ path: "", error: err instanceof Error ? err.message : String(err) }];
|
|
351
492
|
}
|
|
@@ -51,6 +51,49 @@ function cloneToolArgs<T>(args: T): T {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Drop trailing removal/hunk-header lines that appear in a streaming diff
|
|
56
|
+
* before the matching `+added` lines have arrived. Without this, a partial
|
|
57
|
+
* apply_patch / hashline preview shows `-old` first and then visibly grows
|
|
58
|
+
* the `+new` block beneath it — the "removals first, additions catching up"
|
|
59
|
+
* jitter. Once the next streaming tick brings the additions in, the trailing
|
|
60
|
+
* block reappears alongside them.
|
|
61
|
+
*/
|
|
62
|
+
function stripTrailingUnbalancedRemoval(diff: string | undefined): string | undefined {
|
|
63
|
+
if (!diff) return diff;
|
|
64
|
+
const lines = diff.split("\n");
|
|
65
|
+
let lastAddIdx = -1;
|
|
66
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
67
|
+
if (lines[i].startsWith("+")) {
|
|
68
|
+
lastAddIdx = i;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
let hasTrailingUnbalanced = false;
|
|
73
|
+
for (let i = lastAddIdx + 1; i < lines.length; i++) {
|
|
74
|
+
const line = lines[i];
|
|
75
|
+
if (line.startsWith("-") || line.startsWith("@@")) {
|
|
76
|
+
hasTrailingUnbalanced = true;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!hasTrailingUnbalanced) return diff;
|
|
81
|
+
if (lastAddIdx === -1) return "";
|
|
82
|
+
return lines.slice(0, lastAddIdx + 1).join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function stabilizeStreamingPreviews(previews: PerFileDiffPreview[]): PerFileDiffPreview[] {
|
|
86
|
+
let changed = false;
|
|
87
|
+
const next = previews.map(preview => {
|
|
88
|
+
if (!preview.diff) return preview;
|
|
89
|
+
const trimmed = stripTrailingUnbalancedRemoval(preview.diff);
|
|
90
|
+
if (trimmed === preview.diff) return preview;
|
|
91
|
+
changed = true;
|
|
92
|
+
return { ...preview, diff: trimmed ?? "" };
|
|
93
|
+
});
|
|
94
|
+
return changed ? next : previews;
|
|
95
|
+
}
|
|
96
|
+
|
|
54
97
|
function isEditLikeToolName(toolName: string): boolean {
|
|
55
98
|
return toolName === "edit" || toolName === "apply_patch";
|
|
56
99
|
}
|
|
@@ -222,16 +265,18 @@ export class ToolExecutionComponent extends Container {
|
|
|
222
265
|
this.#editDiffAbort = controller;
|
|
223
266
|
|
|
224
267
|
try {
|
|
268
|
+
const isStreaming = !this.#argsComplete;
|
|
225
269
|
const previews = await strategy.computeDiffPreview(effectiveArgs, {
|
|
226
270
|
cwd: this.#cwd,
|
|
227
271
|
signal: controller.signal,
|
|
228
272
|
fuzzyThreshold: this.#editFuzzyThreshold,
|
|
229
273
|
allowFuzzy: this.#editAllowFuzzy,
|
|
230
274
|
hashlineAutoDropPureInsertDuplicates: this.#hashlineAutoDropPureInsertDuplicates,
|
|
275
|
+
isStreaming,
|
|
231
276
|
});
|
|
232
277
|
if (controller.signal.aborted) return;
|
|
233
278
|
if (previews) {
|
|
234
|
-
this.#editDiffPreview = previews;
|
|
279
|
+
this.#editDiffPreview = isStreaming ? stabilizeStreamingPreviews(previews) : previews;
|
|
235
280
|
this.#updateDisplay();
|
|
236
281
|
this.#ui.requestRender();
|
|
237
282
|
}
|
|
@@ -584,11 +584,17 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
584
584
|
if (!this.loopModeEnabled || !this.loopPrompt) return;
|
|
585
585
|
const prompt = this.loopPrompt;
|
|
586
586
|
const loopAction = settings.get("loop.mode");
|
|
587
|
+
this.#deferLoopAutoSubmit(() => {
|
|
588
|
+
void this.#runLoopIteration(loopAction, prompt);
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
#deferLoopAutoSubmit(callback: () => void): void {
|
|
587
593
|
// Brief delay so the user has a chance to press Esc between iterations.
|
|
588
594
|
this.#loopAutoSubmitTimer = setTimeout(() => {
|
|
589
595
|
this.#loopAutoSubmitTimer = undefined;
|
|
590
596
|
if (!this.loopModeEnabled || !this.onInputCallback) return;
|
|
591
|
-
|
|
597
|
+
callback();
|
|
592
598
|
}, 800);
|
|
593
599
|
}
|
|
594
600
|
|
|
@@ -641,7 +647,32 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
641
647
|
}
|
|
642
648
|
}
|
|
643
649
|
|
|
650
|
+
#isLoopAutoSubmitBlocked(): boolean {
|
|
651
|
+
return this.session.isStreaming || this.session.isCompacting;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
#submitLoopPromptWhenReady(prompt: string): void {
|
|
655
|
+
if (!this.loopModeEnabled || this.loopPrompt !== prompt || !this.onInputCallback) return;
|
|
656
|
+
if (isLoopDurationExpired(this.loopLimit)) {
|
|
657
|
+
this.disableLoopMode("Loop time limit reached. Loop mode disabled.");
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (this.#isLoopAutoSubmitBlocked()) {
|
|
661
|
+
this.#deferLoopAutoSubmit(() => this.#submitLoopPromptWhenReady(prompt));
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
this.onInputCallback(this.startPendingSubmission({ text: prompt }));
|
|
665
|
+
}
|
|
666
|
+
|
|
644
667
|
async #runLoopIteration(action: "prompt" | "compact" | "reset", prompt: string): Promise<void> {
|
|
668
|
+
if (!this.loopModeEnabled || this.loopPrompt !== prompt || !this.onInputCallback) return;
|
|
669
|
+
if (this.#isLoopAutoSubmitBlocked()) {
|
|
670
|
+
this.#deferLoopAutoSubmit(() => {
|
|
671
|
+
void this.#runLoopIteration(action, prompt);
|
|
672
|
+
});
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
|
|
645
676
|
if (!consumeLoopLimitIteration(this.loopLimit)) {
|
|
646
677
|
this.disableLoopMode("Loop limit reached. Loop mode disabled.");
|
|
647
678
|
return;
|
|
@@ -652,12 +683,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
652
683
|
} else if (action === "reset") {
|
|
653
684
|
await this.handleClearCommand();
|
|
654
685
|
}
|
|
655
|
-
|
|
656
|
-
if (isLoopDurationExpired(this.loopLimit)) {
|
|
657
|
-
this.disableLoopMode("Loop time limit reached. Loop mode disabled.");
|
|
658
|
-
return;
|
|
659
|
-
}
|
|
660
|
-
this.onInputCallback(this.startPendingSubmission({ text: prompt }));
|
|
686
|
+
this.#submitLoopPromptWhenReady(prompt);
|
|
661
687
|
}
|
|
662
688
|
|
|
663
689
|
disableLoopMode(message = "Loop mode disabled."): void {
|
|
@@ -174,6 +174,25 @@ export function findApiKey(): string | null {
|
|
|
174
174
|
return getEnvApiKey("perplexity") ?? null;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Decode a Perplexity JWT's `exp` claim, in ms. Returns `undefined` when the
|
|
179
|
+
* token has no `exp` (which is the common case — Perplexity sessions are
|
|
180
|
+
* server-side and effectively non-expiring from the client's POV).
|
|
181
|
+
*/
|
|
182
|
+
function jwtExpiryMs(token: string): number | undefined {
|
|
183
|
+
const parts = token.split(".");
|
|
184
|
+
if (parts.length !== 3) return undefined;
|
|
185
|
+
const payload = parts[1];
|
|
186
|
+
if (!payload) return undefined;
|
|
187
|
+
try {
|
|
188
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp?: unknown };
|
|
189
|
+
if (typeof decoded.exp !== "number" || !Number.isFinite(decoded.exp)) return undefined;
|
|
190
|
+
return decoded.exp * 1000;
|
|
191
|
+
} catch {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
177
196
|
async function findOAuthToken(): Promise<string | null> {
|
|
178
197
|
const now = Date.now();
|
|
179
198
|
try {
|
|
@@ -183,7 +202,11 @@ async function findOAuthToken(): Promise<string | null> {
|
|
|
183
202
|
if (record.credential.type !== "oauth") continue;
|
|
184
203
|
const credential = record.credential as PerplexityOAuthCredential;
|
|
185
204
|
if (!credential.access) continue;
|
|
186
|
-
if
|
|
205
|
+
// Trust the JWT's own `exp` claim if it has one; otherwise treat as
|
|
206
|
+
// non-expiring. The stored `expires` field is unreliable: older logins
|
|
207
|
+
// wrote `loginTime + 1h` even though Perplexity JWTs typically lack `exp`.
|
|
208
|
+
const jwtExpiry = jwtExpiryMs(credential.access);
|
|
209
|
+
if (jwtExpiry !== undefined && jwtExpiry <= now + OAUTH_EXPIRY_BUFFER_MS) continue;
|
|
187
210
|
return credential.access;
|
|
188
211
|
}
|
|
189
212
|
} catch {
|