@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
@@ -0,0 +1,144 @@
1
+ import { closeSync, openSync, readSync, statSync, unlinkSync, writeSync } from "node:fs";
2
+ import {
3
+ auditImageLine,
4
+ emptyImageAudit,
5
+ stripImageLine,
6
+ type ImageScopeOpts,
7
+ type StripMode,
8
+ type TranscriptImageAudit,
9
+ } from "./transcript-images.ts";
10
+
11
+ /**
12
+ * File-level image auditing and removal that never holds a transcript in memory.
13
+ *
14
+ * The in-memory helpers take the whole transcript as one string, which forces callers to
15
+ * refuse anything large — and a transcript only becomes worth cleaning once it *is* large,
16
+ * so that cap turned the feature off exactly where it was needed. Reading a line at a time
17
+ * removes the cap and the memory spike together: peak usage is one record, not one file.
18
+ */
19
+
20
+ /**
21
+ * Copy `src` over `dest`, truncating it, without allocating either file in memory.
22
+ *
23
+ * `openSync(dest, "w")` truncates and writes through the existing inode, so a CLI holding
24
+ * the path open in append mode keeps writing to the file callers can still see.
25
+ */
26
+ function copyOverInPlace(src: string, dest: string): void {
27
+ const buf = Buffer.allocUnsafe(1 << 20);
28
+ const from = openSync(src, "r");
29
+ const to = openSync(dest, "w");
30
+ try {
31
+ for (;;) {
32
+ const n = readSync(from, buf, 0, buf.length, null);
33
+ if (n === 0) break;
34
+ writeSync(to, buf, 0, n);
35
+ }
36
+ } finally {
37
+ closeSync(from);
38
+ closeSync(to);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Feed a file to a callback one newline-delimited record at a time.
44
+ *
45
+ * `terminated` says whether the record was followed by a newline in the source. A rewrite has
46
+ * to reproduce that exactly: the CLI appends to this file, and a transcript left without its
47
+ * final newline would have the next record glued onto the last one, corrupting both.
48
+ */
49
+ async function forEachLine(
50
+ path: string,
51
+ onLine: (line: string, terminated: boolean) => void,
52
+ ): Promise<void> {
53
+ const reader = Bun.file(path).stream().getReader();
54
+ const decoder = new TextDecoder();
55
+ let carry = "";
56
+ try {
57
+ for (;;) {
58
+ const { done, value } = await reader.read();
59
+ if (done) break;
60
+ const parts = (carry + decoder.decode(value, { stream: true })).split("\n");
61
+ carry = parts.pop() ?? "";
62
+ for (const line of parts) onLine(line, true);
63
+ }
64
+ } finally {
65
+ reader.releaseLock();
66
+ }
67
+ if (carry) onLine(carry, false);
68
+ }
69
+
70
+ export async function auditTranscriptImagesFile(path: string): Promise<TranscriptImageAudit> {
71
+ const audit = emptyImageAudit();
72
+ await forEachLine(path, (line) => auditImageLine(audit, line));
73
+ return audit;
74
+ }
75
+
76
+ export interface StripFileResult {
77
+ removed: number;
78
+ bytesFreed: number;
79
+ remaining: TranscriptImageAudit;
80
+ }
81
+
82
+ /**
83
+ * Rewrite a transcript with its image payloads replaced, keeping the same inode.
84
+ *
85
+ * The rewrite goes to a sibling file first so a crash midway cannot leave a half-written
86
+ * transcript, and is then copied back over the original rather than renamed into place: the
87
+ * CLI holds this path open in append mode, and a rename would leave it writing to an inode
88
+ * nothing reads any more.
89
+ *
90
+ * Returns without touching the original when there was nothing to remove, so a no-op strip
91
+ * cannot change the file's mtime.
92
+ */
93
+ export async function stripTranscriptImagesFile(
94
+ path: string,
95
+ mode: StripMode,
96
+ opts: ImageScopeOpts = {},
97
+ ): Promise<StripFileResult> {
98
+ const tmp = `${path}.strip-${process.pid}`;
99
+ let removed = 0;
100
+ let bytesFreed = 0;
101
+ const remaining = emptyImageAudit();
102
+ // The rewrite replaces the whole file, so anything appended after this point would be lost.
103
+ // Callers are expected to stop the session first; this catches the case where they did not,
104
+ // because the damage is silent — a dropped record breaks the parentUuid chain and the UI
105
+ // renders the conversation as empty from that point back.
106
+ const before = statSync(path);
107
+
108
+ const writer = Bun.file(tmp).writer();
109
+ try {
110
+ await forEachLine(path, (line, terminated) => {
111
+ const r = stripImageLine(line, mode, opts);
112
+ removed += r.removed;
113
+ bytesFreed += r.bytesFreed;
114
+ auditImageLine(remaining, r.line);
115
+ // Reproduce the source's separators byte for byte, trailing newline included.
116
+ writer.write(terminated ? r.line + "\n" : r.line);
117
+ });
118
+ await writer.end();
119
+
120
+ if (removed === 0) {
121
+ unlinkSync(tmp);
122
+ return { removed, bytesFreed, remaining };
123
+ }
124
+ // Guard against writing a truncated transcript over a good one.
125
+ if (statSync(tmp).size === 0 && before.size > 0) {
126
+ unlinkSync(tmp);
127
+ throw new Error("Refusing to replace transcript with an empty rewrite");
128
+ }
129
+ const after = statSync(path);
130
+ if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) {
131
+ unlinkSync(tmp);
132
+ throw new Error(
133
+ "Transcript changed while it was being rewritten — the session is still running. " +
134
+ "Nothing was modified; stop the session and try again.",
135
+ );
136
+ }
137
+ copyOverInPlace(tmp, path);
138
+ unlinkSync(tmp);
139
+ return { removed, bytesFreed, remaining };
140
+ } catch (e) {
141
+ try { unlinkSync(tmp); } catch { /* already gone */ }
142
+ throw e;
143
+ }
144
+ }
@@ -9,16 +9,25 @@ import { base64ByteLength, imagePlaceholderText } from "../shared/tool-result-co
9
9
  * MANY_IMAGE_DIMENSION_LIMIT on a side and reports that it removed them — so those payloads
10
10
  * cost bandwidth and produce an error while contributing nothing to the model's context.
11
11
  *
12
- * Only images inside tool results are touched. An image the user attached to a message may
13
- * no longer exist anywhere else, whereas a tool result can be produced again by re-reading
14
- * the file it came from.
12
+ * Images live in two places, and they are not equally safe to remove. A tool result can be
13
+ * produced again by re-reading the file it came from, so those are removed by default. An
14
+ * image the user attached to a message usually exists nowhere else, so it is left alone
15
+ * unless the caller opts in — which it must be able to do, because a single oversized
16
+ * attachment is enough to make every later turn of the session fail outright.
15
17
  *
16
- * The payload lives at `message.content[].content[]`. A record also carries an image-shaped
17
- * echo at the top-level `toolUseResult` field, but that one holds no base64 and is not part
18
- * of an API request, so it is left as the CLI wrote it.
18
+ * A tool result's payload sits at `message.content[].content[]`; an attachment sits directly
19
+ * at `message.content[]`. A record also carries an image-shaped echo at the top-level
20
+ * `toolUseResult` field, but that one holds no base64 and is not part of an API request, so
21
+ * it is left as the CLI wrote it.
19
22
  */
20
23
 
21
- /** Per-image dimension cap the API applies once a request carries several images. */
24
+ /**
25
+ * Per-image dimension cap the API applies once a request carries several images.
26
+ *
27
+ * The cap is a maximum, so an image measuring exactly this many pixels is already over it.
28
+ * Comparisons here are `>=` rather than `>` for that reason: treating 2000px as acceptable
29
+ * hides the one image the API is rejecting and makes a cleanup look like a no-op.
30
+ */
22
31
  export const MANY_IMAGE_DIMENSION_LIMIT = 2000;
23
32
 
24
33
  /** Base64 characters decoded to inspect a header — a JPEG frame can sit behind EXIF. */
@@ -26,10 +35,20 @@ const HEADER_CHARS = 4096;
26
35
 
27
36
  export type StripMode = "oversized" | "all";
28
37
 
38
+ /** Which of the two image locations a scan or strip should cover. */
39
+ export interface ImageScopeOpts {
40
+ /**
41
+ * Also cover images the user attached to their own messages. Off by default: the payload
42
+ * in the transcript is typically the only remaining copy, so removing it is lossy in a way
43
+ * removing a tool result is not.
44
+ */
45
+ includeAttachments?: boolean;
46
+ }
47
+
29
48
  export interface TranscriptImageAudit {
30
- /** Images found in tool results. */
49
+ /** Images found in scope. */
31
50
  total: number;
32
- /** How many exceed the dimension cap and are therefore already being dropped. */
51
+ /** How many are at or over the dimension cap and are therefore already being rejected. */
33
52
  oversized: number;
34
53
  /** Decoded size of all of them. */
35
54
  bytes: number;
@@ -37,6 +56,10 @@ export interface TranscriptImageAudit {
37
56
  oversizedBytes: number;
38
57
  /** Longest side seen, or 0 when no dimensions could be read. */
39
58
  largestSide: number;
59
+ /** Of `total`, how many are user attachments rather than tool results. */
60
+ attachments: number;
61
+ /** Of `oversized`, how many are user attachments — these need `includeAttachments` to clear. */
62
+ oversizedAttachments: number;
40
63
  }
41
64
 
42
65
  export interface StripResult {
@@ -74,13 +97,26 @@ function visitImages(node: unknown, visit: Visitor): void {
74
97
  for (const value of Object.values(node)) visitImages(value, visit);
75
98
  }
76
99
 
77
- /** Images carried by tool results only — attachments on user messages are left alone. */
78
- function visitToolResultImages(record: unknown, visit: Visitor): void {
100
+ /**
101
+ * Images carried by one record, in whichever of the two locations are in scope.
102
+ *
103
+ * The visitor is told which location an image came from so callers can count and report
104
+ * attachments separately — a user needs to know when the image blocking their session is one
105
+ * only they can authorise removing.
106
+ */
107
+ function visitRecordImages(
108
+ record: unknown,
109
+ visit: (image: ImageBlock, replace: (block: unknown) => void, isAttachment: boolean) => void,
110
+ ): void {
79
111
  const content = (record as { message?: { content?: unknown } } | null)?.message?.content;
80
112
  if (!Array.isArray(content)) return;
81
- for (const block of content) {
82
- if ((block as { type?: unknown } | null)?.type !== "tool_result") continue;
83
- visitImages((block as { content?: unknown }).content, visit);
113
+ for (let i = 0; i < content.length; i++) {
114
+ const block = content[i];
115
+ if ((block as { type?: unknown } | null)?.type === "tool_result") {
116
+ visitImages((block as { content?: unknown }).content, (img, replace) => visit(img, replace, false));
117
+ } else if (isImageBlock(block)) {
118
+ visit(block, (next) => { content[i] = next; }, true);
119
+ }
84
120
  }
85
121
  }
86
122
 
@@ -99,76 +135,103 @@ function longestSide(data: string): number {
99
135
  return size ? Math.max(size.w, size.h) : 0;
100
136
  }
101
137
 
102
- export function auditTranscriptImages(text: string): TranscriptImageAudit {
103
- const audit: TranscriptImageAudit = {
138
+ /** A zeroed audit, ready to accumulate records into. */
139
+ export function emptyImageAudit(): TranscriptImageAudit {
140
+ return {
104
141
  total: 0,
105
142
  oversized: 0,
106
143
  bytes: 0,
107
144
  oversizedBytes: 0,
108
145
  largestSide: 0,
146
+ attachments: 0,
147
+ oversizedAttachments: 0,
109
148
  };
149
+ }
110
150
 
111
- for (const line of text.split("\n")) {
112
- if (!line) continue;
113
- let record: unknown;
114
- try {
115
- record = JSON.parse(line);
116
- } catch {
117
- continue;
118
- }
119
- visitToolResultImages(record, (image) => {
120
- const data = imageData(image);
121
- const bytes = base64ByteLength(data);
122
- const side = longestSide(data);
123
- audit.total++;
124
- audit.bytes += bytes;
125
- if (side > audit.largestSide) audit.largestSide = side;
126
- if (side > MANY_IMAGE_DIMENSION_LIMIT) {
127
- audit.oversized++;
128
- audit.oversizedBytes += bytes;
129
- }
130
- });
151
+ /**
152
+ * Fold one JSONL record into a running audit.
153
+ *
154
+ * Exposed per line so a transcript can be measured while it streams; a session large enough
155
+ * to be worth auditing is exactly the one too large to hold in memory as a single string.
156
+ */
157
+ export function auditImageLine(audit: TranscriptImageAudit, line: string): void {
158
+ if (!line) return;
159
+ let record: unknown;
160
+ try {
161
+ record = JSON.parse(line);
162
+ } catch {
163
+ return;
131
164
  }
165
+ visitRecordImages(record, (image, _replace, isAttachment) => {
166
+ const data = imageData(image);
167
+ const bytes = base64ByteLength(data);
168
+ const side = longestSide(data);
169
+ audit.total++;
170
+ audit.bytes += bytes;
171
+ if (isAttachment) audit.attachments++;
172
+ if (side > audit.largestSide) audit.largestSide = side;
173
+ if (side >= MANY_IMAGE_DIMENSION_LIMIT) {
174
+ audit.oversized++;
175
+ audit.oversizedBytes += bytes;
176
+ if (isAttachment) audit.oversizedAttachments++;
177
+ }
178
+ });
179
+ }
132
180
 
181
+ export function auditTranscriptImages(text: string): TranscriptImageAudit {
182
+ const audit = emptyImageAudit();
183
+ for (const line of text.split("\n")) auditImageLine(audit, line);
133
184
  return audit;
134
185
  }
135
186
 
136
187
  /**
137
- * Replace image payloads with the standard placeholder text.
188
+ * Rewrite one JSONL record with its in-scope image payloads replaced by placeholder text.
138
189
  *
139
- * `oversized` leaves an image whose dimensions cannot be read, since removing something
140
- * that may well be within the cap would lose context for no benefit.
190
+ * `oversized` keeps an image whose dimensions cannot be read, since removing something that
191
+ * may well be within the cap would lose context for no benefit. Attachments are kept unless
192
+ * `includeAttachments` is set — see the note at the top of this file.
141
193
  */
142
- export function stripTranscriptImages(text: string, mode: StripMode): StripResult {
194
+ export function stripImageLine(
195
+ line: string,
196
+ mode: StripMode,
197
+ opts: ImageScopeOpts = {},
198
+ ): { line: string; removed: number; bytesFreed: number } {
199
+ if (!line) return { line, removed: 0, bytesFreed: 0 };
200
+ let record: unknown;
201
+ try {
202
+ record = JSON.parse(line);
203
+ } catch {
204
+ // A line that does not parse is left byte-for-byte as it was found.
205
+ return { line, removed: 0, bytesFreed: 0 };
206
+ }
207
+
143
208
  let removed = 0;
144
209
  let bytesFreed = 0;
145
- let changed = false;
210
+ visitRecordImages(record, (image, replace, isAttachment) => {
211
+ if (isAttachment && !opts.includeAttachments) return;
212
+ const data = imageData(image);
213
+ if (mode === "oversized" && longestSide(data) < MANY_IMAGE_DIMENSION_LIMIT) return;
214
+ const bytes = base64ByteLength(data);
215
+ replace({ type: "text", text: imagePlaceholderText(bytes || undefined) });
216
+ removed++;
217
+ bytesFreed += bytes;
218
+ });
146
219
 
147
- const lines = text.split("\n").map((line) => {
148
- if (!line) return line;
149
- let record: unknown;
150
- try {
151
- record = JSON.parse(line);
152
- } catch {
153
- // A line that does not parse is left byte-for-byte as it was found.
154
- return line;
155
- }
220
+ return removed > 0 ? { line: JSON.stringify(record), removed, bytesFreed } : { line, removed: 0, bytesFreed: 0 };
221
+ }
156
222
 
157
- let touched = false;
158
- visitToolResultImages(record, (image, replace) => {
159
- const data = imageData(image);
160
- if (mode === "oversized" && longestSide(data) <= MANY_IMAGE_DIMENSION_LIMIT) return;
161
- const bytes = base64ByteLength(data);
162
- replace({ type: "text", text: imagePlaceholderText(bytes || undefined) });
163
- removed++;
164
- bytesFreed += bytes;
165
- touched = true;
166
- });
167
-
168
- if (!touched) return line;
169
- changed = true;
170
- return JSON.stringify(record);
223
+ export function stripTranscriptImages(
224
+ text: string,
225
+ mode: StripMode,
226
+ opts: ImageScopeOpts = {},
227
+ ): StripResult {
228
+ let removed = 0;
229
+ let bytesFreed = 0;
230
+ const lines = text.split("\n").map((line) => {
231
+ const r = stripImageLine(line, mode, opts);
232
+ removed += r.removed;
233
+ bytesFreed += r.bytesFreed;
234
+ return r.line;
171
235
  });
172
-
173
- return { text: changed ? lines.join("\n") : text, removed, bytesFreed };
236
+ return { text: removed > 0 ? lines.join("\n") : text, removed, bytesFreed };
174
237
  }
@@ -57,6 +57,44 @@ export async function checkForUpdate(): Promise<{
57
57
  }
58
58
  }
59
59
 
60
+ const LATEST_VERSION_CACHE_MS = 300_000; // 5min
61
+
62
+ /**
63
+ * Build a cached, in-flight-deduped "what is the newest published version"
64
+ * check. Every open UI polls the upgrade status once a minute, so an uncached
65
+ * check would turn into one registry request per client per minute.
66
+ *
67
+ * The TTL throttles *attempts*, not just successes: a machine that is offline
68
+ * or being rate-limited would otherwise re-enter a doomed 10s fetch on every
69
+ * poll. A failed attempt keeps the last known good version rather than
70
+ * forgetting it.
71
+ */
72
+ export function createLatestVersionCheck(
73
+ checkFn: typeof checkForUpdate = checkForUpdate,
74
+ ttlMs = LATEST_VERSION_CACHE_MS,
75
+ ): () => Promise<string | null> {
76
+ let cache: { at: number; version: string | null } | null = null;
77
+ let inflight: Promise<string | null> | null = null;
78
+
79
+ const remember = (version: string | null) => {
80
+ cache = { at: Date.now(), version: version ?? cache?.version ?? null };
81
+ return cache.version;
82
+ };
83
+
84
+ return function getLatestVersion() {
85
+ if (cache && Date.now() - cache.at < ttlMs) return Promise.resolve(cache.version);
86
+ if (inflight) return inflight;
87
+ inflight = checkFn()
88
+ .then((r) => remember(r.latest))
89
+ .catch(() => remember(null))
90
+ .finally(() => { inflight = null; });
91
+ return inflight;
92
+ };
93
+ }
94
+
95
+ /** Newest published version — shared cache for the upgrade status endpoint. */
96
+ export const getLatestPublishedVersion = createLatestVersionCheck();
97
+
60
98
  /** Resolve npm binary next to the running node runtime, falling back to bare "npm".
61
99
  * Same rationale as bun: autostart may not have npm's bin dir on $PATH. */
62
100
  function resolveNpmBin(): string {
package/src/types/api.ts CHANGED
@@ -70,6 +70,7 @@ export type ChatWsServerMessage =
70
70
  | { type: "phase_changed"; phase: SessionPhase; elapsed?: number }
71
71
  | { type: "session_state"; sessionId: string; phase: SessionPhase; pendingApproval: { requestId: string; tool: string; input: unknown } | null; sessionTitle: string | null; model?: string; effort?: string; thinking?: boolean }
72
72
  | { type: "turn_events"; events: unknown[] }
73
+ | { type: "user_message"; content: string; imageCount?: number; timestamp?: string }
73
74
  | { type: "title_updated"; title: string }
74
75
  | { type: "compact_status"; status: "compacting" | "done" }
75
76
  | { type: "ping" };
@@ -35,6 +35,8 @@ interface ImageAudit {
35
35
  bytes: number;
36
36
  oversizedBytes: number;
37
37
  largestSide: number;
38
+ attachments: number;
39
+ oversizedAttachments: number;
38
40
  limit: number;
39
41
  }
40
42
 
@@ -95,12 +97,12 @@ function TranscriptImages({ sessionId, projectName }: { sessionId: string; proje
95
97
  return () => { live = false; };
96
98
  }, [sessionId, projectName]);
97
99
 
98
- const strip = (mode: "oversized" | "all") => {
100
+ const strip = (mode: "oversized" | "all", includeAttachments = false) => {
99
101
  setBusy(mode);
100
102
  setError(null);
101
103
  api.post<{ removed: number; bytesFreed: number; remaining: ImageAudit }>(
102
104
  `${projectUrl(projectName)}/chat/sessions/${sessionId}/images/strip?project=${encodeURIComponent(projectName)}`,
103
- { mode },
105
+ { mode, includeAttachments },
104
106
  ).then((r) => {
105
107
  setAudit(r.remaining);
106
108
  setFreed({ removed: r.removed, bytes: r.bytesFreed });
@@ -116,12 +118,13 @@ function TranscriptImages({ sessionId, projectName }: { sessionId: string; proje
116
118
 
117
119
  {audit.total === 0 ? (
118
120
  <p className="text-[11px] text-text-subtle">
119
- No images in tool results{freed ? " — transcript is clean." : "."}
121
+ No images in this transcript{freed ? " — it is clean." : "."}
120
122
  </p>
121
123
  ) : (
122
124
  <p className="text-[11px] text-text-secondary">
123
- {audit.total} image{audit.total === 1 ? "" : "s"} in tool results · {fmtSize(audit.bytes)} of base64,
124
- replayed on every turn.
125
+ {audit.total} image{audit.total === 1 ? "" : "s"} · {fmtSize(audit.bytes)} of base64, replayed on
126
+ every turn
127
+ {audit.attachments > 0 && ` · ${audit.attachments} attached by you`}.
125
128
  </p>
126
129
  )}
127
130
 
@@ -129,13 +132,36 @@ function TranscriptImages({ sessionId, projectName }: { sessionId: string; proje
129
132
  <div className="flex gap-2 rounded-md border border-warning/40 bg-warning/10 p-2 text-[11px] text-text-secondary">
130
133
  <TriangleAlert className="mt-px size-3.5 shrink-0 text-warning" />
131
134
  <span>
132
- {audit.oversized} image{audit.oversized === 1 ? " is" : "s are"} wider than {audit.limit}px
133
- (largest {audit.largestSide}px). The API rejects those once a request carries several images,
134
- so they already contribute nothing — removing them loses no context.
135
+ {audit.oversized} image{audit.oversized === 1 ? " reaches" : "s reach"} the {audit.limit}px
136
+ limit (largest {audit.largestSide}px). Once a request carries several images the API rejects
137
+ those outright, which fails the whole turn — removing them loses no context.
135
138
  </span>
136
139
  </div>
137
140
  )}
138
141
 
142
+ {audit.oversizedAttachments > 0 && (
143
+ <div className="flex flex-col gap-2 rounded-md border border-error/40 bg-error/10 p-2 text-[11px] text-text-secondary">
144
+ <div className="flex gap-2">
145
+ <TriangleAlert className="mt-px size-3.5 shrink-0 text-error" />
146
+ <span>
147
+ {audit.oversizedAttachments} of those {audit.oversizedAttachments === 1 ? "is an image you" : "are images you"}
148
+ {" "}attached yourself, so the buttons below leave {audit.oversizedAttachments === 1 ? "it" : "them"} in place.
149
+ While {audit.oversizedAttachments === 1 ? "it stays" : "they stay"}, every turn in this session keeps failing.
150
+ The transcript holds the only copy — removing {audit.oversizedAttachments === 1 ? "it" : "them"} cannot be undone.
151
+ </span>
152
+ </div>
153
+ <Button
154
+ size="sm"
155
+ variant="destructive"
156
+ disabled={busy !== null}
157
+ onClick={() => strip("oversized", true)}
158
+ >
159
+ {busy === "oversized" ? <Loader2 className="size-3.5 animate-spin" /> : <ImageOff className="size-3.5" />}
160
+ Remove oversized, attachments included
161
+ </Button>
162
+ </div>
163
+ )}
164
+
139
165
  {freed && (
140
166
  <p className="text-[11px] text-success">
141
167
  Removed {freed.removed} image{freed.removed === 1 ? "" : "s"}, freed {fmtSize(freed.bytes)}.
@@ -166,8 +192,8 @@ function TranscriptImages({ sessionId, projectName }: { sessionId: string; proje
166
192
  </Button>
167
193
  </div>
168
194
  <p className="text-[11px] text-text-subtle">
169
- Images you attached to a message are never touched. A removed tool image is replaced by its
170
- placeholder text; the chat still shows the file, re-read from disk.
195
+ These two buttons cover tool images only, and leave anything you attached alone. A removed tool
196
+ image is replaced by its placeholder text; the chat still shows the file, re-read from disk.
171
197
  </p>
172
198
  </div>
173
199
  );
@@ -610,6 +610,26 @@ export function useChat(sessionId: string | null, providerId = "claude", project
610
610
  // on the global bus (`use-global-events.ts`) instead of here — a chat socket is
611
611
  // not guaranteed to exist since chat tabs mount lazily.
612
612
 
613
+ // A user message sent from another device/tab of this session — render its
614
+ // bubble. The sender never receives this echo (server excludes the sender),
615
+ // so no dedupe against the optimistic append is needed.
616
+ if ((data as any).type === "user_message") {
617
+ const content = (data as any).content as string;
618
+ setMessages((prev) => {
619
+ const last = prev[prev.length - 1];
620
+ // Dedupe: a concurrent turn_events replay or REST refetch may already
621
+ // have appended this same turn's user message.
622
+ if (last?.role === "user" && last.content === content) return prev;
623
+ return [...prev, {
624
+ id: `user-remote-${Date.now()}`,
625
+ role: "user" as const,
626
+ content,
627
+ timestamp: (data as any).timestamp ?? new Date().toISOString(),
628
+ }];
629
+ });
630
+ return;
631
+ }
632
+
613
633
  // Handle title updates from SDK summary
614
634
  if ((data as any).type === "title_updated") {
615
635
  setSessionTitle((data as any).title ?? null);