@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +3657 -3639
  2. package/CLAUDE.md +1 -0
  3. package/assets/skills/ppm/SKILL.md +1 -1
  4. package/assets/skills/ppm/references/cli-reference.md +4 -4
  5. package/assets/skills/ppm/references/http-api.md +1 -1
  6. package/bun.lock +10 -10
  7. package/dist/web/assets/{ai-resource-editor-C6whUvqU.js → ai-resource-editor-DxM8JFQ0.js} +1 -1
  8. package/dist/web/assets/{audio-preview-zCaVGICs.js → audio-preview-DE1sEUmx.js} +1 -1
  9. package/dist/web/assets/{chat-tab-34EBK6a-.js → chat-tab-BKZAcGU7.js} +4 -4
  10. package/dist/web/assets/{code-editor-B-3Ys0pn.js → code-editor-DjconF_F.js} +2 -2
  11. package/dist/web/assets/{conflict-editor-CmbzavrH.js → conflict-editor-CLfc44RC.js} +1 -1
  12. package/dist/web/assets/{csv-preview-DSe1UKLQ.js → csv-preview-DxD081uz.js} +1 -1
  13. package/dist/web/assets/{database-viewer-BC3tMeSZ.js → database-viewer-Du1HOmex.js} +1 -1
  14. package/dist/web/assets/{diff-viewer-CH7HtuY_.js → diff-viewer-CeznVBNn.js} +1 -1
  15. package/dist/web/assets/{docx-preview-CXw0EiKN.js → docx-preview-DYbOm4bo.js} +1 -1
  16. package/dist/web/assets/{extension-webview-BKNPTGJ4.js → extension-webview-BYBwAaZv.js} +1 -1
  17. package/dist/web/assets/{git-log-panel-4at7vGet.js → git-log-panel-CgUkbt6i.js} +1 -1
  18. package/dist/web/assets/{glide-data-grid-BJBoXCv5.js → glide-data-grid-CBLhaW5g.js} +1 -1
  19. package/dist/web/assets/{group-chat-tab-XNIsuLbW.js → group-chat-tab-5EKPaL_r.js} +1 -1
  20. package/dist/web/assets/{image-preview-Hqu7-Pbv.js → image-preview-DjqmBn2G.js} +1 -1
  21. package/dist/web/assets/index-6_hleyhK.css +2 -0
  22. package/dist/web/assets/{index-BR5KVuwx.js → index-nsuPdCaW.js} +3 -3
  23. package/dist/web/assets/{keybindings-store-B_JMJIhN.js → keybindings-store-DTJlq-2C.js} +1 -1
  24. package/dist/web/assets/{markdown-renderer-CPm8QbIv.js → markdown-renderer-BqthPP7U.js} +1 -1
  25. package/dist/web/assets/{markdown-renderer-WnJVcJ2v.js → markdown-renderer-CrZWFsD-.js} +1 -1
  26. package/dist/web/assets/{notification-store-CD8j6i20.js → notification-store-ClZEc-lT.js} +1 -1
  27. package/dist/web/assets/{pdf-preview-CA71gMQU.js → pdf-preview-BZY4PbMF.js} +1 -1
  28. package/dist/web/assets/{postgres-viewer-CkZu5Ary.js → postgres-viewer-DzpIkZtk.js} +1 -1
  29. package/dist/web/assets/{settings-tab-Cy2JvRdN.js → settings-tab-o3KSdtxR.js} +1 -1
  30. package/dist/web/assets/{sql-query-editor-DxaQ6kWk.js → sql-query-editor-COXQHj5D.js} +1 -1
  31. package/dist/web/assets/{sqlite-viewer-1fDZK9Jx.js → sqlite-viewer-Dew5JH18.js} +1 -1
  32. package/dist/web/assets/{system-monitor-tab-B64gFhnE.js → system-monitor-tab-CwGbHdi_.js} +1 -1
  33. package/dist/web/assets/{terminal-tab-Dl-I2wBS.js → terminal-tab-DqdmayEL.js} +1 -1
  34. package/dist/web/assets/{tool-cards-HHgeNBqk.js → tool-cards-CNuiAf2X.js} +2 -2
  35. package/dist/web/assets/{use-monaco-theme-uP8s-9Qb.js → use-monaco-theme-uvD7nRfD.js} +1 -1
  36. package/dist/web/assets/{video-preview-sGT5PM0H.js → video-preview-BDpCb2Pd.js} +1 -1
  37. package/dist/web/index.html +2 -2
  38. package/dist/web/sw.js +1 -1
  39. package/package.json +106 -106
  40. package/packages/vscode-compat/node_modules/.bin/tsc.bunx +0 -0
  41. package/packages/vscode-compat/node_modules/.bin/tsc.exe +0 -0
  42. package/packages/vscode-compat/node_modules/.bin/tsserver.bunx +0 -0
  43. package/packages/vscode-compat/node_modules/.bin/tsserver.exe +0 -0
  44. package/scripts/check-ppm-dir-usage.sh +0 -0
  45. package/scripts/install.sh +0 -0
  46. package/scripts/release.sh +0 -0
  47. package/src/providers/claude-agent-sdk.ts +116 -2
  48. package/src/providers/image-limit-detection.ts +30 -0
  49. package/src/server/routes/chat.ts +24 -21
  50. package/src/server/routes/upgrade.ts +28 -12
  51. package/src/server/ws/chat.ts +27 -0
  52. package/src/services/slash-discovery/discover-skill-roots.ts +22 -3
  53. package/src/services/slash-discovery/index.ts +1 -0
  54. package/src/services/slash-discovery/rewrite-slash-alias.ts +35 -0
  55. package/src/services/slash-discovery/skill-loader.ts +40 -5
  56. package/src/services/slash-discovery/types.ts +6 -0
  57. package/src/services/transcript-images-file.ts +144 -0
  58. package/src/services/transcript-images.ts +129 -66
  59. package/src/services/upgrade.service.ts +38 -0
  60. package/src/types/api.ts +1 -0
  61. package/src/web/components/chat/session-debug-dialog.tsx +36 -10
  62. package/src/web/hooks/use-chat.ts +20 -0
  63. 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) + (usage.outputTokens ?? 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 { auditTranscriptImages, stripTranscriptImages, MANY_IMAGE_DIMENSION_LIMIT, type StripMode } from "../../services/transcript-images.ts";
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 and read it, or explain why that was not possible.
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 readTranscript(sessionId: string): { path: string; text: string } | { error: string; status: 400 | 404 | 413 } {
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
- if (statSync(jsonlPath).size > MAX_TRANSCRIPT_SCAN_BYTES) {
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 read = readTranscript(c.req.param("id"));
780
- if ("error" in read) return c.json(err(read.error), read.status);
781
- return c.json(ok({ ...auditTranscriptImages(read.text), limit: MANY_IMAGE_DIMENSION_LIMIT }));
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 read = readTranscript(sessionId);
807
- if ("error" in read) return c.json(err(read.error), read.status);
810
+ const found = locateTranscript(sessionId);
811
+ if ("error" in found) return c.json(err(found.error), found.status);
808
812
 
809
- const result = stripTranscriptImages(read.text, mode);
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: { ...auditTranscriptImages(result.text), limit: MANY_IMAGE_DIMENSION_LIMIT },
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
- /** GET / upgrade status (current version, available version, install method) */
17
- upgradeRoutes.get("/", (c) => {
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
- if (existsSync(resolve(getPpmDir(), "status.json"))) {
21
- const data = JSON.parse(readFileSync(resolve(getPpmDir(), "status.json"), "utf-8"));
22
- const candidate = data.availableVersion ?? null;
23
- // Only report if actually newer than current version
24
- if (candidate && compareSemver(VERSION, candidate) < 0) {
25
- availableVersion = candidate;
26
- }
27
- }
28
- } catch {}
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,
@@ -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 name = rel.replace(/\.md$/, "").split(sep).join("/");
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: str(meta.name) ?? 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) ?? pathName;
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) ?? pathName;
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 name = str(meta.name) ?? rel.replace(/\.md$/, "").split(sep).join("/");
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 */