@hienlh/ppm 0.17.46 → 0.17.48
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 +3657 -3639
- package/CLAUDE.md +1 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/cli-reference.md +4 -4
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/bun.lock +10 -10
- package/dist/web/assets/{ai-resource-editor-C6whUvqU.js → ai-resource-editor-DxM8JFQ0.js} +1 -1
- package/dist/web/assets/{audio-preview-zCaVGICs.js → audio-preview-DE1sEUmx.js} +1 -1
- package/dist/web/assets/{chat-tab-34EBK6a-.js → chat-tab-BKZAcGU7.js} +4 -4
- package/dist/web/assets/{code-editor-B-3Ys0pn.js → code-editor-DjconF_F.js} +2 -2
- package/dist/web/assets/{conflict-editor-CmbzavrH.js → conflict-editor-CLfc44RC.js} +1 -1
- package/dist/web/assets/{csv-preview-DSe1UKLQ.js → csv-preview-DxD081uz.js} +1 -1
- package/dist/web/assets/{database-viewer-BC3tMeSZ.js → database-viewer-Du1HOmex.js} +1 -1
- package/dist/web/assets/{diff-viewer-CH7HtuY_.js → diff-viewer-CeznVBNn.js} +1 -1
- package/dist/web/assets/{docx-preview-CXw0EiKN.js → docx-preview-DYbOm4bo.js} +1 -1
- package/dist/web/assets/{extension-webview-BKNPTGJ4.js → extension-webview-BYBwAaZv.js} +1 -1
- package/dist/web/assets/{git-log-panel-4at7vGet.js → git-log-panel-CgUkbt6i.js} +1 -1
- package/dist/web/assets/{glide-data-grid-BJBoXCv5.js → glide-data-grid-CBLhaW5g.js} +1 -1
- package/dist/web/assets/{group-chat-tab-XNIsuLbW.js → group-chat-tab-5EKPaL_r.js} +1 -1
- package/dist/web/assets/{image-preview-Hqu7-Pbv.js → image-preview-DjqmBn2G.js} +1 -1
- package/dist/web/assets/index-6_hleyhK.css +2 -0
- package/dist/web/assets/{index-BR5KVuwx.js → index-nsuPdCaW.js} +3 -3
- package/dist/web/assets/{keybindings-store-B_JMJIhN.js → keybindings-store-DTJlq-2C.js} +1 -1
- package/dist/web/assets/{markdown-renderer-CPm8QbIv.js → markdown-renderer-BqthPP7U.js} +1 -1
- package/dist/web/assets/{markdown-renderer-WnJVcJ2v.js → markdown-renderer-CrZWFsD-.js} +1 -1
- package/dist/web/assets/{notification-store-CD8j6i20.js → notification-store-ClZEc-lT.js} +1 -1
- package/dist/web/assets/{pdf-preview-CA71gMQU.js → pdf-preview-BZY4PbMF.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CkZu5Ary.js → postgres-viewer-DzpIkZtk.js} +1 -1
- package/dist/web/assets/{settings-tab-Cy2JvRdN.js → settings-tab-o3KSdtxR.js} +1 -1
- package/dist/web/assets/{sql-query-editor-DxaQ6kWk.js → sql-query-editor-COXQHj5D.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-1fDZK9Jx.js → sqlite-viewer-Dew5JH18.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-B64gFhnE.js → system-monitor-tab-CwGbHdi_.js} +1 -1
- package/dist/web/assets/{terminal-tab-Dl-I2wBS.js → terminal-tab-DqdmayEL.js} +1 -1
- package/dist/web/assets/{tool-cards-HHgeNBqk.js → tool-cards-CNuiAf2X.js} +2 -2
- package/dist/web/assets/{use-monaco-theme-uP8s-9Qb.js → use-monaco-theme-uvD7nRfD.js} +1 -1
- package/dist/web/assets/{video-preview-sGT5PM0H.js → video-preview-BDpCb2Pd.js} +1 -1
- package/dist/web/index.html +2 -2
- package/dist/web/sw.js +1 -1
- package/package.json +106 -106
- package/packages/vscode-compat/node_modules/.bin/tsc.bunx +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsc.exe +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsserver.bunx +0 -0
- package/packages/vscode-compat/node_modules/.bin/tsserver.exe +0 -0
- package/scripts/check-ppm-dir-usage.sh +0 -0
- package/scripts/install.sh +0 -0
- package/scripts/release.sh +0 -0
- package/src/providers/claude-agent-sdk.ts +116 -2
- package/src/providers/image-limit-detection.ts +30 -0
- package/src/server/routes/chat.ts +24 -21
- package/src/server/routes/upgrade.ts +28 -12
- package/src/server/ws/chat.ts +27 -0
- package/src/services/slash-discovery/discover-skill-roots.ts +22 -3
- package/src/services/slash-discovery/index.ts +1 -0
- package/src/services/slash-discovery/rewrite-slash-alias.ts +35 -0
- package/src/services/slash-discovery/skill-loader.ts +40 -5
- package/src/services/slash-discovery/types.ts +6 -0
- package/src/services/transcript-images-file.ts +144 -0
- package/src/services/transcript-images.ts +129 -66
- package/src/services/upgrade.service.ts +38 -0
- package/src/types/api.ts +1 -0
- package/src/web/components/chat/session-debug-dialog.tsx +36 -10
- package/src/web/hooks/use-chat.ts +20 -0
- package/dist/web/assets/index-hIIXGoXQ.css +0 -2
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
getSessionMessages,
|
|
6
6
|
} from "@anthropic-ai/claude-agent-sdk";
|
|
7
7
|
import { buildModelQueryOptions } from "./claude-agent-sdk-query-options.ts";
|
|
8
|
+
import { isImageLimitRejection } from "./image-limit-detection.ts";
|
|
8
9
|
import type {
|
|
9
10
|
AIProvider,
|
|
10
11
|
Session,
|
|
@@ -332,6 +333,56 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
332
333
|
return null;
|
|
333
334
|
}
|
|
334
335
|
|
|
336
|
+
/**
|
|
337
|
+
* Remove the image payloads the API is refusing from a session's transcript.
|
|
338
|
+
*
|
|
339
|
+
* Called mid-turn, with the CLI subprocess for this session already torn down by the retry
|
|
340
|
+
* path, so nothing is appending while the file is rewritten. The transcript is the only
|
|
341
|
+
* place these images live, and the CLI replays it verbatim, so editing the file is the only
|
|
342
|
+
* way to stop them being re-sent.
|
|
343
|
+
*
|
|
344
|
+
* `includeAttachments` is the second, lossier pass: images the user attached are normally
|
|
345
|
+
* left alone, but once they are what is failing every turn, keeping them costs the whole
|
|
346
|
+
* session.
|
|
347
|
+
*/
|
|
348
|
+
private async stripSessionImages(
|
|
349
|
+
sessionId: string,
|
|
350
|
+
includeAttachments: boolean,
|
|
351
|
+
): Promise<{ removed: number; bytesFreed: number; reason: string; failed: boolean }> {
|
|
352
|
+
const dir = resolve(CLAUDE_PROJECTS_DIR);
|
|
353
|
+
if (!existsSync(dir)) return { removed: 0, bytesFreed: 0, reason: "no transcript directory", failed: true };
|
|
354
|
+
let jsonlPath = "";
|
|
355
|
+
for (const sub of readdirSync(dir)) {
|
|
356
|
+
const candidate = resolve(dir, sub, `${sessionId}.jsonl`);
|
|
357
|
+
if (existsSync(candidate)) { jsonlPath = candidate; break; }
|
|
358
|
+
}
|
|
359
|
+
if (!jsonlPath) return { removed: 0, bytesFreed: 0, reason: "transcript not found", failed: true };
|
|
360
|
+
|
|
361
|
+
try {
|
|
362
|
+
const { stripTranscriptImagesFile } = await import("../services/transcript-images-file.ts");
|
|
363
|
+
const r = await stripTranscriptImagesFile(jsonlPath, "oversized", { includeAttachments });
|
|
364
|
+
if (r.removed > 0) return { removed: r.removed, bytesFreed: r.bytesFreed, reason: "", failed: false };
|
|
365
|
+
// Only images at or over the measurable cap are ever removed automatically. Falling back
|
|
366
|
+
// to "all" here would delete in-range images too, on nothing better than a guess that the
|
|
367
|
+
// API is applying a cap we cannot see — and for an attachment the transcript holds the
|
|
368
|
+
// only copy. When no image measures oversized, stop and let the user decide from the
|
|
369
|
+
// session debug dialog, which still offers the unrestricted removal.
|
|
370
|
+
return {
|
|
371
|
+
removed: 0,
|
|
372
|
+
bytesFreed: 0,
|
|
373
|
+
reason: includeAttachments
|
|
374
|
+
? "no image measures over the dimension cap"
|
|
375
|
+
: "no oversized images found",
|
|
376
|
+
failed: false,
|
|
377
|
+
};
|
|
378
|
+
} catch (e) {
|
|
379
|
+
// A throw means the rewrite was refused, most often because the transcript grew while it
|
|
380
|
+
// was being read. That says nothing about whether images are there to remove, so the
|
|
381
|
+
// caller must not read it as "nothing found" and escalate to the lossy pass.
|
|
382
|
+
return { removed: 0, bytesFreed: 0, reason: (e as Error).message, failed: true };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
335
386
|
private buildQueryEnv(
|
|
336
387
|
_projectPath: string | undefined,
|
|
337
388
|
account: { id: string; accessToken: string } | null,
|
|
@@ -1158,8 +1209,11 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
1158
1209
|
// Allow 2 refresh attempts per turn (token can rotate mid-conversation).
|
|
1159
1210
|
// Counter resets on successful turn (see result handler) so next turn gets a fresh budget.
|
|
1160
1211
|
const MAX_AUTH_RETRIES = 2;
|
|
1212
|
+
// Two passes: oversized images first, then attachments. A third would have nothing left.
|
|
1213
|
+
const MAX_IMAGE_RETRIES = 2;
|
|
1161
1214
|
let retryCount = 0;
|
|
1162
1215
|
let rateLimitRetryCount = 0;
|
|
1216
|
+
let imageRetryCount = 0;
|
|
1163
1217
|
let authRetryCount = 0;
|
|
1164
1218
|
let hadAnyEvents = false;
|
|
1165
1219
|
// Accounts that hit a hard usage/session limit this turn — never retried again here.
|
|
@@ -1435,6 +1489,16 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
1435
1489
|
}
|
|
1436
1490
|
}
|
|
1437
1491
|
|
|
1492
|
+
// An image the API refuses poisons every later turn: the transcript is replayed in
|
|
1493
|
+
// full each time, so the same rejected payload comes back and the session can never
|
|
1494
|
+
// make progress again. Recognised even when `error` is already set, since the API
|
|
1495
|
+
// reports it as invalid_request — a code that also covers faults which must surface
|
|
1496
|
+
// as-is. See image-limit-detection.ts for why wording alone is not enough.
|
|
1497
|
+
if (isImageLimitRejection(msg)) {
|
|
1498
|
+
assistantError = "image_limit";
|
|
1499
|
+
console.warn(`[sdk] session=${sessionId} API rejected an image in the replayed transcript — will strip and retry`);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1438
1502
|
if (assistantError) {
|
|
1439
1503
|
// Dump full SDK message for debugging
|
|
1440
1504
|
console.error(`[sdk] session=${sessionId} cwd=${effectiveCwd} assistant error: ${assistantError} (isFirst=${isFirstMessage} retry=${retryCount})`);
|
|
@@ -1553,6 +1617,49 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
1553
1617
|
break;
|
|
1554
1618
|
}
|
|
1555
1619
|
|
|
1620
|
+
// An image in the replayed transcript is being refused. Nothing about retrying the
|
|
1621
|
+
// same bytes can succeed, and nothing else will ever remove them — the CLI keeps
|
|
1622
|
+
// replaying the file — so the payload is taken out of the transcript and the turn
|
|
1623
|
+
// is retried against the trimmed history.
|
|
1624
|
+
//
|
|
1625
|
+
// Escalates: first pass drops only images at or over the dimension cap, which is
|
|
1626
|
+
// lossless because the API already refuses them. Only if that changed nothing does
|
|
1627
|
+
// it take the attachments too, since by then the session is unusable either way and
|
|
1628
|
+
// a lost screenshot beats a dead conversation.
|
|
1629
|
+
if (assistantError === "image_limit") {
|
|
1630
|
+
if (imageRetryCount >= MAX_IMAGE_RETRIES) {
|
|
1631
|
+
console.warn(`[sdk] session=${sessionId} image limit persists after ${imageRetryCount} strip attempt(s) — stopping`);
|
|
1632
|
+
yield { type: "error", message: "The API keeps refusing an image in this conversation, and removing the offending images did not clear it. Start a new session, or use /compact to summarise the history away." };
|
|
1633
|
+
break;
|
|
1634
|
+
}
|
|
1635
|
+
const escalate = imageRetryCount > 0;
|
|
1636
|
+
imageRetryCount++;
|
|
1637
|
+
yield { type: "status_update", phase: "retrying", message: escalate ? "Removing attached images..." : "Removing oversized images..." };
|
|
1638
|
+
// Close the subprocess before touching the file it appends to. Rewriting a
|
|
1639
|
+
// transcript underneath a live CLI drops whatever it wrote in the meantime,
|
|
1640
|
+
// which breaks the parentUuid chain and hides the history from that point back.
|
|
1641
|
+
closeCurrentStream();
|
|
1642
|
+
let stripped = await this.stripSessionImages(sessionId, escalate);
|
|
1643
|
+
// Nothing oversized among the tool results, but an attachment can be over the cap
|
|
1644
|
+
// too and only the opt-in pass reaches those. Skipped when the strip failed rather
|
|
1645
|
+
// than came up empty — a refused rewrite is not evidence that attachments are the
|
|
1646
|
+
// problem, and this pass is the one that deletes the only copy of them.
|
|
1647
|
+
if (stripped.removed === 0 && !escalate && !stripped.failed) {
|
|
1648
|
+
console.warn(`[sdk] session=${sessionId} nothing oversized to strip — escalating to attachments`);
|
|
1649
|
+
imageRetryCount++;
|
|
1650
|
+
yield { type: "status_update", phase: "retrying", message: "Removing attached images..." };
|
|
1651
|
+
stripped = await this.stripSessionImages(sessionId, true);
|
|
1652
|
+
}
|
|
1653
|
+
if (stripped.removed === 0) {
|
|
1654
|
+
console.warn(`[sdk] session=${sessionId} nothing left to strip (${stripped.reason}) — stopping`);
|
|
1655
|
+
yield { type: "error", message: `The API refused an image in this conversation, but nothing could be removed automatically (${stripped.reason}). Open Session debug to remove images yourself, start a new session, or use /compact to summarise the history away.` };
|
|
1656
|
+
break;
|
|
1657
|
+
}
|
|
1658
|
+
console.warn(`[sdk] session=${sessionId} stripped ${stripped.removed} image(s), ${(stripped.bytesFreed / 1048576).toFixed(2)}MB — retrying turn`);
|
|
1659
|
+
eventSource = rebuildQuery(account);
|
|
1660
|
+
continue retryLoop;
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1556
1663
|
const errorHints: Record<string, string> = {
|
|
1557
1664
|
authentication_failed: "API authentication failed. Check your account credentials in Settings → Accounts.",
|
|
1558
1665
|
billing_error: "Billing error on this account. Check your subscription status.",
|
|
@@ -1773,13 +1880,20 @@ export class ClaudeAgentSdkProvider implements AIProvider {
|
|
|
1773
1880
|
resultSubtype = subtype;
|
|
1774
1881
|
resultNumTurns = result.num_turns as number | undefined;
|
|
1775
1882
|
|
|
1776
|
-
// Extract context window usage from modelUsage
|
|
1883
|
+
// Extract context window usage from modelUsage.
|
|
1884
|
+
// Cached prefix tokens occupy the context window exactly like fresh ones — they are
|
|
1885
|
+
// only cheaper, not absent. Leaving them out made a warm session, where most of the
|
|
1886
|
+
// prefix arrives as a cache read, report a fraction of the context it truly holds,
|
|
1887
|
+
// so the meter stayed low while the session grew past the point of being affordable.
|
|
1777
1888
|
const modelUsage = (result.modelUsage ?? result.model_usage) as Record<string, any> | undefined;
|
|
1778
1889
|
if (modelUsage) {
|
|
1779
1890
|
for (const usage of Object.values(modelUsage)) {
|
|
1780
1891
|
const cw = usage.contextWindow ?? 0;
|
|
1781
1892
|
if (cw > 0) {
|
|
1782
|
-
const total = (usage.inputTokens ?? 0)
|
|
1893
|
+
const total = (usage.inputTokens ?? 0)
|
|
1894
|
+
+ (usage.cacheReadInputTokens ?? 0)
|
|
1895
|
+
+ (usage.cacheCreationInputTokens ?? 0)
|
|
1896
|
+
+ (usage.outputTokens ?? 0);
|
|
1783
1897
|
resultContextWindowPct = Math.min(Math.round((total / cw) * 100), 100);
|
|
1784
1898
|
break;
|
|
1785
1899
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recognise the API refusing an image that the replayed transcript keeps re-sending.
|
|
3
|
+
*
|
|
4
|
+
* The API reports this as `invalid_request`, a code that also covers faults which must surface
|
|
5
|
+
* to the user untouched, so the wording is what identifies it. Two phrasings are in use: one
|
|
6
|
+
* naming the dimension cap and telling the user to start a new session, and a softer one saying
|
|
7
|
+
* the image was removed.
|
|
8
|
+
*
|
|
9
|
+
* Wording alone is not enough to act on. A hit tears down the subprocess and rewrites the
|
|
10
|
+
* user's transcript, dropping image payloads — and for an attachment that payload is usually
|
|
11
|
+
* the only copy left. The same phrases are ordinary in any session that merely talks about
|
|
12
|
+
* images ("the hero image is too large", "try fewer images"), so an ungated match would do
|
|
13
|
+
* that damage to a perfectly healthy turn. Only the synthetic error record the CLI writes
|
|
14
|
+
* carries `isApiErrorMessage`, which is what separates the two.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const IMAGE_REJECTION_WORDING =
|
|
18
|
+
/image[\s\S]{0,80}(dimension limit|could not be processed|too large)|fewer images/i;
|
|
19
|
+
|
|
20
|
+
/** True when this assistant message is the API refusing an image, not prose about one. */
|
|
21
|
+
export function isImageLimitRejection(msg: unknown): boolean {
|
|
22
|
+
if ((msg as { isApiErrorMessage?: unknown } | null)?.isApiErrorMessage !== true) return false;
|
|
23
|
+
const content = (msg as { message?: { content?: unknown } }).message?.content;
|
|
24
|
+
if (!Array.isArray(content)) return false;
|
|
25
|
+
const text = content
|
|
26
|
+
.filter((b: unknown) => (b as { type?: unknown })?.type === "text" && typeof (b as { text?: unknown }).text === "string")
|
|
27
|
+
.map((b: unknown) => (b as { text: string }).text)
|
|
28
|
+
.join("");
|
|
29
|
+
return text.length > 0 && IMAGE_REJECTION_WORDING.test(text);
|
|
30
|
+
}
|
|
@@ -14,7 +14,8 @@ import { getCachedUsage, refreshUsageNow } from "../../services/claude-usage.ser
|
|
|
14
14
|
import { getSessionLog } from "../../services/session-log.service.ts";
|
|
15
15
|
import { parseJsonlTranscript, validateJsonlPath } from "../../services/jsonl-transcript-parser.ts";
|
|
16
16
|
import { aggregateTasks } from "../../services/task-status-aggregator.ts";
|
|
17
|
-
import {
|
|
17
|
+
import { MANY_IMAGE_DIMENSION_LIMIT, type StripMode } from "../../services/transcript-images.ts";
|
|
18
|
+
import { auditTranscriptImagesFile, stripTranscriptImagesFile } from "../../services/transcript-images-file.ts";
|
|
18
19
|
import { getSessionProjectPath, setSessionMetadata, setSessionTitle, getSessionTitle, getPinnedSessionIds, pinSession, unpinSession, deleteSessionMapping, deleteSessionMetadata, deleteSessionTitle, getAllUnread, clearSessionUnread, setSessionUnread } from "../../services/db.service.ts";
|
|
19
20
|
import { setSessionTag, bulkSetSessionTag, getTagById, getSessionTags, getProjectDefaultTagId } from "../../services/tag.service.ts";
|
|
20
21
|
import { recordBranch, resolveVersionGroup, resolveVersionMap, collapseTreesToHeads, hasChildren, deleteBranchesFor, getRootId } from "../../services/session-branch.service.ts";
|
|
@@ -754,31 +755,30 @@ function cacheHitRateOf(r: { input_tokens: number; cache_read_tokens: number; ca
|
|
|
754
755
|
return prefix > 0 ? r.cache_read_tokens / prefix : 0;
|
|
755
756
|
}
|
|
756
757
|
|
|
757
|
-
/** Largest transcript the image routes will read into memory in one go. */
|
|
758
|
-
const MAX_TRANSCRIPT_SCAN_BYTES = 64 * 1024 * 1024;
|
|
759
|
-
|
|
760
758
|
/**
|
|
761
|
-
* Locate a session transcript
|
|
759
|
+
* Locate a session transcript, or explain why that was not possible.
|
|
762
760
|
*
|
|
763
761
|
* The id becomes a filename, and the strip route writes to what this returns, so anything
|
|
764
762
|
* that is not a plain session id is refused before it can reach into another directory.
|
|
763
|
+
*
|
|
764
|
+
* No size limit: both routes stream the file a record at a time, so a transcript costs one
|
|
765
|
+
* line of memory rather than its whole length. The cap that used to live here turned the
|
|
766
|
+
* cleanup off for exactly the transcripts big enough to need it.
|
|
765
767
|
*/
|
|
766
|
-
function
|
|
768
|
+
function locateTranscript(sessionId: string): { path: string } | { error: string; status: 400 | 404 } {
|
|
767
769
|
if (!/^[A-Za-z0-9_-]{8,64}$/.test(sessionId)) return { error: "Invalid session id", status: 400 };
|
|
768
770
|
const { jsonlPath, exists } = resolveSessionJsonlPath(sessionId);
|
|
769
771
|
if (!exists || !jsonlPath) return { error: "Transcript not found for this session", status: 404 };
|
|
770
|
-
|
|
771
|
-
return { error: "Transcript too large to scan", status: 413 };
|
|
772
|
-
}
|
|
773
|
-
return { path: jsonlPath, text: readFileSync(jsonlPath, "utf8") };
|
|
772
|
+
return { path: jsonlPath };
|
|
774
773
|
}
|
|
775
774
|
|
|
776
775
|
/** GET /chat/sessions/:id/images — count the image payloads the transcript replays every turn */
|
|
777
|
-
chatRoutes.get("/sessions/:id/images", (c) => {
|
|
776
|
+
chatRoutes.get("/sessions/:id/images", async (c) => {
|
|
778
777
|
try {
|
|
779
|
-
const
|
|
780
|
-
if ("error" in
|
|
781
|
-
|
|
778
|
+
const found = locateTranscript(c.req.param("id"));
|
|
779
|
+
if ("error" in found) return c.json(err(found.error), found.status);
|
|
780
|
+
const audit = await auditTranscriptImagesFile(found.path);
|
|
781
|
+
return c.json(ok({ ...audit, limit: MANY_IMAGE_DIMENSION_LIMIT }));
|
|
782
782
|
} catch (e) {
|
|
783
783
|
return c.json(err((e as Error).message), 500);
|
|
784
784
|
}
|
|
@@ -787,31 +787,34 @@ chatRoutes.get("/sessions/:id/images", (c) => {
|
|
|
787
787
|
/**
|
|
788
788
|
* POST /chat/sessions/:id/images/strip — replace image payloads with their placeholder text.
|
|
789
789
|
*
|
|
790
|
-
* Rewrites the transcript in place rather than swapping in a new file: the CLI holds the
|
|
791
|
-
* original open in append mode, so a rename would leave it writing to an orphaned inode.
|
|
792
790
|
* A turn in flight is refused outright, since the record it is midway through appending
|
|
793
791
|
* would be lost by the rewrite.
|
|
792
|
+
*
|
|
793
|
+
* `includeAttachments` also clears images the user attached to their own messages. It is
|
|
794
|
+
* opt-in because the transcript usually holds the only copy, but it has to be reachable: one
|
|
795
|
+
* oversized attachment makes every later turn of the session fail, and nothing else can
|
|
796
|
+
* remove it.
|
|
794
797
|
*/
|
|
795
798
|
chatRoutes.post("/sessions/:id/images/strip", async (c) => {
|
|
796
799
|
try {
|
|
797
800
|
const sessionId = c.req.param("id");
|
|
798
801
|
const body = await c.req.json().catch(() => ({}));
|
|
799
802
|
const mode: StripMode = (body as { mode?: unknown }).mode === "all" ? "all" : "oversized";
|
|
803
|
+
const includeAttachments = (body as { includeAttachments?: unknown }).includeAttachments === true;
|
|
800
804
|
|
|
801
805
|
const { listRunningSessions } = await import("../ws/chat.ts");
|
|
802
806
|
if (listRunningSessions().some((s) => s.sessionId === sessionId)) {
|
|
803
807
|
return c.json(err("Session is running — wait for the turn to finish"), 409);
|
|
804
808
|
}
|
|
805
809
|
|
|
806
|
-
const
|
|
807
|
-
if ("error" in
|
|
810
|
+
const found = locateTranscript(sessionId);
|
|
811
|
+
if ("error" in found) return c.json(err(found.error), found.status);
|
|
808
812
|
|
|
809
|
-
const result =
|
|
810
|
-
if (result.removed > 0) writeFileSync(read.path, result.text);
|
|
813
|
+
const result = await stripTranscriptImagesFile(found.path, mode, { includeAttachments });
|
|
811
814
|
return c.json(ok({
|
|
812
815
|
removed: result.removed,
|
|
813
816
|
bytesFreed: result.bytesFreed,
|
|
814
|
-
remaining: { ...
|
|
817
|
+
remaining: { ...result.remaining, limit: MANY_IMAGE_DIMENSION_LIMIT },
|
|
815
818
|
}));
|
|
816
819
|
} catch (e) {
|
|
817
820
|
return c.json(err((e as Error).message), 500);
|
|
@@ -7,25 +7,41 @@ import {
|
|
|
7
7
|
compareSemver,
|
|
8
8
|
applyUpgrade,
|
|
9
9
|
signalSupervisorUpgrade,
|
|
10
|
+
getLatestPublishedVersion,
|
|
10
11
|
} from "../../services/upgrade.service.ts";
|
|
11
12
|
import { ok, err } from "../../types/api.ts";
|
|
12
13
|
import { getPpmDir } from "../../services/ppm-dir.ts";
|
|
13
14
|
|
|
14
15
|
export const upgradeRoutes = new Hono();
|
|
15
16
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
let availableVersion: string | null = null;
|
|
17
|
+
/** Last version the supervisor's periodic check recorded, if any. */
|
|
18
|
+
function readRecordedVersion(): string | null {
|
|
19
19
|
try {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
20
|
+
const path = resolve(getPpmDir(), "status.json");
|
|
21
|
+
if (!existsSync(path)) return null;
|
|
22
|
+
return JSON.parse(readFileSync(path, "utf-8")).availableVersion ?? null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check the registry directly rather than only reading what the supervisor
|
|
30
|
+
* recorded: that record is written 5min after startup and every 15min after,
|
|
31
|
+
* so a UI opened inside that window would see no update and offer no upgrade.
|
|
32
|
+
* The supervisor's record is the offline fallback.
|
|
33
|
+
*/
|
|
34
|
+
export async function resolveAvailableVersion(
|
|
35
|
+
getLatest: () => Promise<string | null> = getLatestPublishedVersion,
|
|
36
|
+
): Promise<string | null> {
|
|
37
|
+
const candidate = (await getLatest()) ?? readRecordedVersion();
|
|
38
|
+
// Only report if actually newer than current version
|
|
39
|
+
return candidate && compareSemver(VERSION, candidate) < 0 ? candidate : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** GET / — upgrade status (current version, available version, install method) */
|
|
43
|
+
upgradeRoutes.get("/", async (c) => {
|
|
44
|
+
const availableVersion = await resolveAvailableVersion();
|
|
29
45
|
|
|
30
46
|
return c.json(ok({
|
|
31
47
|
currentVersion: VERSION,
|
package/src/server/ws/chat.ts
CHANGED
|
@@ -947,6 +947,33 @@ export const chatWebSocket = {
|
|
|
947
947
|
setSessionThinking(sessionId, parsed.thinking ? THINKING_ADAPTIVE : 0);
|
|
948
948
|
}
|
|
949
949
|
|
|
950
|
+
// Kits that self-namespace their skills (AgentKit's `/ak:debug`) publish a
|
|
951
|
+
// name the runtime never registers — it names plugin items after the plugin
|
|
952
|
+
// and directory instead. Rewrite before the echo so every consumer (other
|
|
953
|
+
// devices, the stored transcript, the SDK) sees the name that actually ran.
|
|
954
|
+
const typedContent = parsed.content.trimStart();
|
|
955
|
+
if (typedContent.startsWith("/")) {
|
|
956
|
+
const { listSlashItems, rewriteSlashAlias } = await import("../../services/slash-discovery/index.ts");
|
|
957
|
+
const canonical = rewriteSlashAlias(typedContent, listSlashItems(entry.projectPath ?? ""));
|
|
958
|
+
if (canonical !== typedContent) parsed.content = canonical;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Echo the user message to OTHER connected clients (second device/tab).
|
|
962
|
+
// The sender renders it optimistically; without this echo a live-connected
|
|
963
|
+
// second device only sees the assistant stream for this turn.
|
|
964
|
+
if (entry.clients.size > 1) {
|
|
965
|
+
const echo = JSON.stringify({
|
|
966
|
+
type: "user_message",
|
|
967
|
+
content: parsed.content,
|
|
968
|
+
imageCount: parsed.images?.length ?? 0,
|
|
969
|
+
timestamp: new Date().toISOString(),
|
|
970
|
+
});
|
|
971
|
+
for (const client of entry.clients) {
|
|
972
|
+
if (client === ws) continue;
|
|
973
|
+
try { client.send(echo); } catch { evictClient(entry, client); }
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
950
977
|
// Intercept PPM-handled built-in commands (e.g. /skills, /version)
|
|
951
978
|
const content = parsed.content.trim();
|
|
952
979
|
const slashMatch = content.match(/^\/(\S+)/);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolve, dirname } from "node:path";
|
|
1
|
+
import { resolve, dirname, basename } from "node:path";
|
|
2
2
|
import { existsSync, statSync, readdirSync, readFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
@@ -39,13 +39,14 @@ function addRoot(
|
|
|
39
39
|
basePath: string,
|
|
40
40
|
origin: ItemOrigin,
|
|
41
41
|
source: DefinitionSource,
|
|
42
|
+
pluginName?: string,
|
|
42
43
|
): void {
|
|
43
44
|
const full = resolve(basePath, origin);
|
|
44
45
|
if (!isDir(full)) return;
|
|
45
46
|
const resolved = resolve(full);
|
|
46
47
|
if (seen.has(resolved)) return;
|
|
47
48
|
seen.add(resolved);
|
|
48
|
-
roots.push({ path: resolved, source, origin });
|
|
49
|
+
roots.push({ path: resolved, source, origin, ...(pluginName && { pluginName }) });
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
/**
|
|
@@ -143,6 +144,23 @@ export function resolveInstalledPlugins(pluginsDir: string): InstalledPlugin[] {
|
|
|
143
144
|
}
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Plugin identifier used as the namespace prefix. The registry key is
|
|
149
|
+
* `plugin-id@marketplace-id`, so the id is everything before the `@`; scanned
|
|
150
|
+
* fallbacks read the manifest, and the directory name is the last resort.
|
|
151
|
+
*/
|
|
152
|
+
export function resolvePluginName(plugin: InstalledPlugin): string {
|
|
153
|
+
const fromKey = plugin.key?.split("@")[0]?.trim();
|
|
154
|
+
if (fromKey) return fromKey;
|
|
155
|
+
try {
|
|
156
|
+
const manifest = JSON.parse(
|
|
157
|
+
readFileSync(resolve(plugin.path, ".claude-plugin", "plugin.json"), "utf-8"),
|
|
158
|
+
) as { name?: unknown };
|
|
159
|
+
if (typeof manifest.name === "string" && manifest.name.trim()) return manifest.name.trim();
|
|
160
|
+
} catch { /* manifest missing or malformed — fall through */ }
|
|
161
|
+
return basename(plugin.path);
|
|
162
|
+
}
|
|
163
|
+
|
|
146
164
|
/** Add roots for Claude Code plugins, which ship their own skills/commands/agents */
|
|
147
165
|
function addPluginRoots(roots: SkillRoot[], seen: Set<string>, projectPath: string): void {
|
|
148
166
|
const pluginsDir = resolve(homedir(), ".claude", "plugins");
|
|
@@ -150,8 +168,9 @@ function addPluginRoots(roots: SkillRoot[], seen: Set<string>, projectPath: stri
|
|
|
150
168
|
const disabled = loadDisabledPluginKeys(projectPath);
|
|
151
169
|
for (const plugin of resolveInstalledPlugins(pluginsDir)) {
|
|
152
170
|
if (plugin.key && disabled.has(plugin.key)) continue;
|
|
171
|
+
const pluginName = resolvePluginName(plugin);
|
|
153
172
|
for (const origin of ORIGINS) {
|
|
154
|
-
addRoot(roots, seen, plugin.path, origin, "user-plugin");
|
|
173
|
+
addRoot(roots, seen, plugin.path, origin, "user-plugin", pluginName);
|
|
155
174
|
}
|
|
156
175
|
}
|
|
157
176
|
}
|
|
@@ -11,6 +11,7 @@ export { isPpmHandled, getBuiltinByName } from "./builtin-commands.ts";
|
|
|
11
11
|
export { executeBuiltin } from "./builtin-handlers.ts";
|
|
12
12
|
export { invalidateCache, invalidateAll } from "./cache.ts";
|
|
13
13
|
export { ensureSdkCommands, invalidateSdkCommands } from "./sdk-commands.ts";
|
|
14
|
+
export { rewriteSlashAlias } from "./rewrite-slash-alias.ts";
|
|
14
15
|
export type { SlashItem, SlashItemWithSource, ShadowedItem, DiscoveryResult, SkillRoot, DefinitionSource } from "./types.ts";
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { SlashItem } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** Split a leading `/name` off a message, if there is one. */
|
|
4
|
+
function splitCommand(content: string): { name: string; rest: string } | null {
|
|
5
|
+
const match = content.match(/^\/(\S+)/);
|
|
6
|
+
if (!match) return null;
|
|
7
|
+
return { name: match[1]!, rest: content.slice(match[0].length) };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Rewrite a leading slash command that used a legacy alias into the name the
|
|
12
|
+
* runtime actually registers.
|
|
13
|
+
*
|
|
14
|
+
* Claude Code names a plugin item after its location, prefixed by the owning
|
|
15
|
+
* plugin (`/ak-engineer:ak-debug`). Kits that instead declare a self-namespaced
|
|
16
|
+
* frontmatter name (AgentKit ships `name: ak:debug`) publish a name nothing can
|
|
17
|
+
* resolve, so users typing the documented `/ak:debug` get a dead command. The
|
|
18
|
+
* declared name is kept as an alias during discovery; this turns it back into
|
|
19
|
+
* the canonical one.
|
|
20
|
+
*
|
|
21
|
+
* When two plugins claim the same alias — the AgentKit engineer and marketing
|
|
22
|
+
* kits overlap on ~30 skills — the first match in discovery order wins, which
|
|
23
|
+
* follows the plugin registry order.
|
|
24
|
+
*
|
|
25
|
+
* Unknown commands pass through untouched: reporting those is the SDK's job.
|
|
26
|
+
*/
|
|
27
|
+
export function rewriteSlashAlias(content: string, items: SlashItem[]): string {
|
|
28
|
+
const cmd = splitCommand(content);
|
|
29
|
+
if (!cmd) return content;
|
|
30
|
+
// A name that already resolves is never rewritten, even if some other item
|
|
31
|
+
// happens to alias it.
|
|
32
|
+
if (items.some((item) => item.name === cmd.name)) return content;
|
|
33
|
+
const hit = items.find((item) => item.aliases?.includes(cmd.name));
|
|
34
|
+
return hit ? `/${hit.name}${cmd.rest}` : content;
|
|
35
|
+
}
|
|
@@ -53,6 +53,34 @@ function walkDir(dir: string, visitor: (filePath: string) => void, visited = new
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Resolve the invocable name for an item found under `root`.
|
|
58
|
+
*
|
|
59
|
+
* Plugin items are namespaced by their owning plugin, but the two kinds differ
|
|
60
|
+
* in what follows the prefix. A skill or command is named after where its file
|
|
61
|
+
* lives, so a kit that self-namespaces instead (AgentKit ships `name: ak:debug`)
|
|
62
|
+
* is not invocable under that declared name — it is kept as an alias so typed
|
|
63
|
+
* input can be rewritten to the canonical one. An agent is named after its
|
|
64
|
+
* frontmatter `name`, which is also how the runtime addresses it for delegation.
|
|
65
|
+
*/
|
|
66
|
+
function qualify(
|
|
67
|
+
root: SkillRoot,
|
|
68
|
+
pathName: string,
|
|
69
|
+
declared: string | undefined,
|
|
70
|
+
nameSource: "path" | "declared" = "path",
|
|
71
|
+
): { name: string; aliases?: string[] } {
|
|
72
|
+
if (!root.pluginName) return { name: declared ?? pathName };
|
|
73
|
+
if (nameSource === "declared") {
|
|
74
|
+
const suffix = declared || pathName;
|
|
75
|
+
return { name: suffix ? `${root.pluginName}:${suffix}` : "" };
|
|
76
|
+
}
|
|
77
|
+
// A SKILL.md sitting at the plugin root has no directory to take a name from
|
|
78
|
+
const suffix = pathName || declared;
|
|
79
|
+
if (!suffix) return { name: "" };
|
|
80
|
+
const name = `${root.pluginName}:${suffix}`;
|
|
81
|
+
return { name, ...(declared && declared !== name && { aliases: [declared] }) };
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
/** Collect commands from a root with origin "commands" */
|
|
57
85
|
function loadCommands(root: SkillRoot): SlashItemWithSource[] {
|
|
58
86
|
const items: SlashItemWithSource[] = [];
|
|
@@ -66,10 +94,13 @@ function loadCommands(root: SkillRoot): SlashItemWithSource[] {
|
|
|
66
94
|
const content = readFileSync(filePath, "utf-8");
|
|
67
95
|
const { meta } = parseFrontmatter(content);
|
|
68
96
|
const rel = relative(root.path, filePath);
|
|
69
|
-
const
|
|
97
|
+
const pathName = rel.replace(/\.md$/, "").split(sep).join("/");
|
|
98
|
+
const { name, aliases } = qualify(root, pathName, str(meta.name));
|
|
99
|
+
if (!name) return;
|
|
70
100
|
items.push({
|
|
71
101
|
type: "command",
|
|
72
|
-
name
|
|
102
|
+
name,
|
|
103
|
+
...(aliases && { aliases }),
|
|
73
104
|
description: str(meta.description) ?? "",
|
|
74
105
|
argumentHint: str(meta["argument-hint"]),
|
|
75
106
|
scope,
|
|
@@ -106,11 +137,12 @@ function loadSkills(root: SkillRoot): SlashItemWithSource[] {
|
|
|
106
137
|
dirsWithSkillMd.add(skillDir);
|
|
107
138
|
const rel = relative(root.path, skillDir);
|
|
108
139
|
const pathName = rel.split(sep).join("/");
|
|
109
|
-
const name = str(meta.name)
|
|
140
|
+
const { name, aliases } = qualify(root, pathName, str(meta.name));
|
|
110
141
|
if (!name) return;
|
|
111
142
|
items.push({
|
|
112
143
|
type: "skill",
|
|
113
144
|
name,
|
|
145
|
+
...(aliases && { aliases }),
|
|
114
146
|
description: str(meta.description) ?? "",
|
|
115
147
|
argumentHint: str(meta["argument-hint"]),
|
|
116
148
|
scope,
|
|
@@ -136,11 +168,12 @@ function loadSkills(root: SkillRoot): SlashItemWithSource[] {
|
|
|
136
168
|
const { meta } = parseFrontmatter(content);
|
|
137
169
|
const rel = relative(root.path, filePath);
|
|
138
170
|
const pathName = rel.replace(/\.md$/, "").split(sep).join("/");
|
|
139
|
-
const name = str(meta.name)
|
|
171
|
+
const { name, aliases } = qualify(root, pathName, str(meta.name));
|
|
140
172
|
if (!name) return;
|
|
141
173
|
items.push({
|
|
142
174
|
type: "skill",
|
|
143
175
|
name,
|
|
176
|
+
...(aliases && { aliases }),
|
|
144
177
|
description: str(meta.description) ?? "",
|
|
145
178
|
argumentHint: str(meta["argument-hint"]),
|
|
146
179
|
scope,
|
|
@@ -168,7 +201,9 @@ function loadAgents(root: SkillRoot): SlashItemWithSource[] {
|
|
|
168
201
|
const content = readFileSync(filePath, "utf-8");
|
|
169
202
|
const { meta } = parseFrontmatter(content);
|
|
170
203
|
const rel = relative(root.path, filePath);
|
|
171
|
-
const
|
|
204
|
+
const pathName = rel.replace(/\.md$/, "").split(sep).join("/");
|
|
205
|
+
const { name } = qualify(root, pathName, str(meta.name), "declared");
|
|
206
|
+
if (!name) return;
|
|
172
207
|
items.push({
|
|
173
208
|
type: "agent",
|
|
174
209
|
name,
|
|
@@ -41,6 +41,12 @@ export interface SkillRoot {
|
|
|
41
41
|
path: string; // Resolved absolute path
|
|
42
42
|
source: DefinitionSource;
|
|
43
43
|
origin: ItemOrigin;
|
|
44
|
+
/**
|
|
45
|
+
* Owning Claude Code plugin, set only for roots under ~/.claude/plugins/.
|
|
46
|
+
* Items below such a root are namespaced `<pluginName>:<path>`, matching how
|
|
47
|
+
* Claude Code itself registers them.
|
|
48
|
+
*/
|
|
49
|
+
pluginName?: string;
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
/** Extends SlashItem with source metadata */
|