termdock 1.4.107 → 1.4.109

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 (79) hide show
  1. package/.desktop-dist/main.js +345 -1
  2. package/dist/client/assets/{ModelPreview-CoR41z2e.js → ModelPreview-Cxk4IZLv.js} +1 -1
  3. package/dist/client/assets/{abnfDiagram-VRR7QNED-CXFrMEcU.js → abnfDiagram-VRR7QNED-Bz2HWRRX.js} +1 -1
  4. package/dist/client/assets/{arc-DXT1yvki.js → arc-D3vf35f_.js} +1 -1
  5. package/dist/client/assets/{architectureDiagram-ZJ3FMSHR-B4Tl5gm4.js → architectureDiagram-ZJ3FMSHR-LI-OXsDp.js} +1 -1
  6. package/dist/client/assets/{blockDiagram-677ZJIJ3-D8OY0bG2.js → blockDiagram-677ZJIJ3-Dm1JtLIo.js} +1 -1
  7. package/dist/client/assets/{c4Diagram-LMCZKHZV-BHIhcBy3.js → c4Diagram-LMCZKHZV-DSkxlBnU.js} +1 -1
  8. package/dist/client/assets/channel-qZtjoGxW.js +1 -0
  9. package/dist/client/assets/{chunk-2Q5K7J3B-NFF-sZCT.js → chunk-2Q5K7J3B-Wd2AO6yQ.js} +1 -1
  10. package/dist/client/assets/{chunk-32BRIVSS-Cysd2WNQ.js → chunk-32BRIVSS-CIBkFIAg.js} +1 -1
  11. package/dist/client/assets/{chunk-5VM5RSS4-BRHAAr29.js → chunk-5VM5RSS4-9veDV0NJ.js} +1 -1
  12. package/dist/client/assets/{chunk-EX3LRPZG-BovDZYVQ.js → chunk-EX3LRPZG-DVi5mqrV.js} +1 -1
  13. package/dist/client/assets/{chunk-JWPE2WC7-ByWUvKGO.js → chunk-JWPE2WC7-DZc_3_5i.js} +1 -1
  14. package/dist/client/assets/{chunk-MOJQB5TN-GWk63RII.js → chunk-MOJQB5TN-Czq1YGEA.js} +1 -1
  15. package/dist/client/assets/{chunk-RYQCIY6F-GvzHMCqG.js → chunk-RYQCIY6F-BN3UqH_7.js} +1 -1
  16. package/dist/client/assets/{chunk-V7JOEXUC-BoiSCBad.js → chunk-V7JOEXUC-BgDCEp_z.js} +1 -1
  17. package/dist/client/assets/{chunk-VR4S4FIN-ByS_rjkq.js → chunk-VR4S4FIN-BnyRVZSn.js} +1 -1
  18. package/dist/client/assets/{chunk-XXDRQBXY-D9fRuB0M.js → chunk-XXDRQBXY-KOa0rQIz.js} +1 -1
  19. package/dist/client/assets/classDiagram-OUVF2IWQ-CnWXW1tk.js +1 -0
  20. package/dist/client/assets/classDiagram-v2-EOCWNBFH-CnWXW1tk.js +1 -0
  21. package/dist/client/assets/{cose-bilkent-JH36ORCC-BVOwkeRc.js → cose-bilkent-JH36ORCC-B-P2BstB.js} +1 -1
  22. package/dist/client/assets/{cynefin-VYW2F7L2-8rNTW_mJ.js → cynefin-VYW2F7L2-DOSg0Q6x.js} +1 -1
  23. package/dist/client/assets/{cynefinDiagram-TSTJHNR4-DHASHiYD.js → cynefinDiagram-TSTJHNR4-Bm8Ct13U.js} +1 -1
  24. package/dist/client/assets/{dagre-VKFMJZFB-wWqVXFNE.js → dagre-VKFMJZFB-Cnw99n0y.js} +1 -1
  25. package/dist/client/assets/{diagram-FQU43EPY-Bp2E7omd.js → diagram-FQU43EPY-B742HZhK.js} +1 -1
  26. package/dist/client/assets/{diagram-G47NLZAW-DajrBIg7.js → diagram-G47NLZAW-BGXKr2u9.js} +1 -1
  27. package/dist/client/assets/{diagram-NH7WQ7WH-ByGTRgNg.js → diagram-NH7WQ7WH-DIyEYN64.js} +1 -1
  28. package/dist/client/assets/{diagram-OA4YK3LP-bsRCtWPe.js → diagram-OA4YK3LP-BdRMWU80.js} +1 -1
  29. package/dist/client/assets/{diagram-WEI45ONY-9f1_0Bjd.js → diagram-WEI45ONY-Bu-180k8.js} +1 -1
  30. package/dist/client/assets/{ebnfDiagram-CCIWWBDH-DyvJDQQ5.js → ebnfDiagram-CCIWWBDH-Djcej7Pq.js} +1 -1
  31. package/dist/client/assets/{erDiagram-Q63AITRT-DwLHRiMz.js → erDiagram-Q63AITRT-D70JxkGr.js} +1 -1
  32. package/dist/client/assets/{flowDiagram-23GEKE2U--9lOgA3z.js → flowDiagram-23GEKE2U-DovWUM1i.js} +1 -1
  33. package/dist/client/assets/{ganttDiagram-NO4QXBWP-j9C0N_DS.js → ganttDiagram-NO4QXBWP-Izsfqq5i.js} +1 -1
  34. package/dist/client/assets/{gitGraphDiagram-IHSO6WYX-lHdVffsh.js → gitGraphDiagram-IHSO6WYX-BTZo0N08.js} +1 -1
  35. package/dist/client/assets/index-BE4Js_ss.js +456 -0
  36. package/dist/client/assets/index-CRIZk117.css +1 -0
  37. package/dist/client/assets/{infoDiagram-FWYZ7A6U-D8kAsmdn.js → infoDiagram-FWYZ7A6U-Ina5uVvZ.js} +1 -1
  38. package/dist/client/assets/{ishikawaDiagram-FXEZZL3T-CTY9BLyE.js → ishikawaDiagram-FXEZZL3T-iUdzuACl.js} +1 -1
  39. package/dist/client/assets/{journeyDiagram-5HDEW3XC-B3QO8wF2.js → journeyDiagram-5HDEW3XC-lh15NShH.js} +1 -1
  40. package/dist/client/assets/{kanban-definition-HUTT4EX6-CesXysl2.js → kanban-definition-HUTT4EX6-CKZooxMa.js} +1 -1
  41. package/dist/client/assets/{linear-wxKR9xNu.js → linear-BrmrQdSX.js} +1 -1
  42. package/dist/client/assets/{mermaid.core-DqsMKQ0n.js → mermaid.core-fbY4nFyi.js} +4 -4
  43. package/dist/client/assets/{mindmap-definition-LN4V7U3C-CCfpEZEP.js → mindmap-definition-LN4V7U3C-BHUhjMUf.js} +1 -1
  44. package/dist/client/assets/{pegDiagram-2B236MQR-DEje4xdU.js → pegDiagram-2B236MQR-8qcHqxsE.js} +1 -1
  45. package/dist/client/assets/{pieDiagram-ENE6RG2P-B5guqQ7K.js → pieDiagram-ENE6RG2P-CpIkkExH.js} +1 -1
  46. package/dist/client/assets/{quadrantDiagram-ABIIQ3AL-D58kwRrP.js → quadrantDiagram-ABIIQ3AL-hWzvYsWG.js} +1 -1
  47. package/dist/client/assets/{railroadDiagram-RFXS5EU6-D9VFfFu0.js → railroadDiagram-RFXS5EU6-2nhrE9bK.js} +1 -1
  48. package/dist/client/assets/{requirementDiagram-TGXJPOKE-B0q4l0cr.js → requirementDiagram-TGXJPOKE-B05mEN7w.js} +1 -1
  49. package/dist/client/assets/{sankeyDiagram-HTMAVEWB-DTsXXi0M.js → sankeyDiagram-HTMAVEWB-CXHkBpDN.js} +1 -1
  50. package/dist/client/assets/{sequenceDiagram-DBY2YBRQ-3Xpi1nmi.js → sequenceDiagram-DBY2YBRQ-C4wG1r_I.js} +1 -1
  51. package/dist/client/assets/{sizeCapture-X5ZJPWSS-C0jHd0eC.js → sizeCapture-X5ZJPWSS-D_3i42tQ.js} +1 -1
  52. package/dist/client/assets/{stateDiagram-2N3HPSRC-DlDm_dYg.js → stateDiagram-2N3HPSRC-DhEgczRA.js} +1 -1
  53. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-D9gqRttI.js +1 -0
  54. package/dist/client/assets/{swimlanes-5IMT3BWC-C128cuo6.js → swimlanes-5IMT3BWC-DTrpFviv.js} +2 -2
  55. package/dist/client/assets/swimlanesDiagram-G3AALYLV-CNOSFfLB.js +8 -0
  56. package/dist/client/assets/{timeline-definition-FHXFAJF6-BHsLELm-.js → timeline-definition-FHXFAJF6-DLpl_8Za.js} +1 -1
  57. package/dist/client/assets/{vennDiagram-L72KCM5P-DkdQFPIy.js → vennDiagram-L72KCM5P-CgnCFHj7.js} +1 -1
  58. package/dist/client/assets/{wardleyDiagram-EHGQE667-DWaahbD5.js → wardleyDiagram-EHGQE667-BwNfD-cz.js} +1 -1
  59. package/dist/client/assets/{xychartDiagram-FW5EYKEG-CSwOYmgm.js → xychartDiagram-FW5EYKEG--sZgo_Fw.js} +1 -1
  60. package/dist/client/index.html +2 -2
  61. package/dist/client/sw.js +1 -1
  62. package/dist/server/agent/automationStore.js +12 -7
  63. package/dist/server/agent/sessionSearchStore.js +135 -5
  64. package/dist/server/entry.js +24 -1
  65. package/dist/server/routes/filesystem.js +31 -6
  66. package/dist/server/routes/terminal.js +6 -2
  67. package/dist/server/utils/heicPreview.js +50 -0
  68. package/dist/server/utils/runtimeMonitor.js +287 -0
  69. package/dist/server/utils/settings.js +9 -0
  70. package/dist/server/workers/heicPreviewWorker.js +14 -0
  71. package/package.json +3 -1
  72. package/runtime-manifest.json +3 -3
  73. package/dist/client/assets/channel-CXw70OW3.js +0 -1
  74. package/dist/client/assets/classDiagram-OUVF2IWQ-CNF7sVVd.js +0 -1
  75. package/dist/client/assets/classDiagram-v2-EOCWNBFH-CNF7sVVd.js +0 -1
  76. package/dist/client/assets/index-B1FKcn-W.js +0 -451
  77. package/dist/client/assets/index-BloZ2WQg.css +0 -1
  78. package/dist/client/assets/stateDiagram-v2-6OUMAXLB-BVOBevpU.js +0 -1
  79. package/dist/client/assets/swimlanesDiagram-G3AALYLV-Bf87D8dr.js +0 -8
@@ -2,6 +2,10 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  const MAX_LOG_BYTES = 2 * 1024 * 1024;
4
4
  const LOG_SEGMENT_BYTES = MAX_LOG_BYTES / 2;
5
+ const DEFAULT_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
6
+ const DEFAULT_MAX_SESSIONS = 50;
7
+ const DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60_000;
8
+ const BUDGET_MAINTENANCE_INTERVAL_MS = 10 * 60_000;
5
9
  const ANSI_PATTERN = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d\/#&.:=?%@~_]+)*)?\u0007)|(?:(?:\d{1,4}(?:[;:]\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
6
10
  function clean(value) {
7
11
  return value
@@ -12,23 +16,36 @@ function clean(value) {
12
16
  }
13
17
  export class SessionSearchStore {
14
18
  directory;
19
+ limits;
15
20
  metadata = new Map();
16
21
  pending = new Map();
17
22
  flushTimer = null;
18
- constructor(directory) {
23
+ metadataDirty = false;
24
+ metadataMustPersist = false;
25
+ lastMetadataWriteAt = 0;
26
+ lastBudgetMaintenanceAt = 0;
27
+ constructor(directory, limits = {}) {
19
28
  this.directory = directory;
29
+ this.limits = limits;
20
30
  this.loadMetadata();
31
+ this.enforceBudget();
21
32
  }
22
33
  append(metadata, output) {
23
34
  const text = clean(output);
35
+ const previous = this.metadata.get(metadata.sessionId);
24
36
  this.metadata.set(metadata.sessionId, { ...metadata, updatedAt: Date.now() });
37
+ this.metadataDirty = true;
38
+ this.metadataMustPersist ||= this.metadataChanged(previous, metadata);
25
39
  if (text.trim())
26
40
  this.pending.set(metadata.sessionId, [...(this.pending.get(metadata.sessionId) ?? []), text]);
27
41
  if (!this.flushTimer)
28
42
  this.flushTimer = setTimeout(() => this.flush(), 750);
29
43
  }
30
44
  update(metadata) {
45
+ const previous = this.metadata.get(metadata.sessionId);
31
46
  this.metadata.set(metadata.sessionId, { ...metadata, updatedAt: Date.now() });
47
+ this.metadataDirty = true;
48
+ this.metadataMustPersist ||= this.metadataChanged(previous, metadata);
32
49
  if (!this.flushTimer)
33
50
  this.flushTimer = setTimeout(() => this.flush(), 750);
34
51
  }
@@ -61,7 +78,7 @@ export class SessionSearchStore {
61
78
  if (this.flushTimer)
62
79
  clearTimeout(this.flushTimer);
63
80
  this.flushTimer = null;
64
- if (this.metadata.size === 0 && this.pending.size === 0)
81
+ if (this.metadata.size === 0 && this.pending.size === 0 && !this.metadataDirty)
65
82
  return;
66
83
  fs.mkdirSync(this.directory, { recursive: true });
67
84
  for (const [sessionId, chunks] of this.pending) {
@@ -84,9 +101,63 @@ export class SessionSearchStore {
84
101
  fs.appendFileSync(logPath, boundedChunk, { mode: 0o600 });
85
102
  }
86
103
  this.pending.clear();
87
- const temporaryPath = `${this.metadataPath()}.${process.pid}.tmp`;
88
- fs.writeFileSync(temporaryPath, JSON.stringify({ version: 1, sessions: [...this.metadata.values()] }, null, 2), { mode: 0o600 });
89
- fs.renameSync(temporaryPath, this.metadataPath());
104
+ const now = Date.now();
105
+ if (this.metadataDirty && (this.metadataMustPersist || now - this.lastMetadataWriteAt >= 30_000))
106
+ this.persistMetadata();
107
+ if (now - this.lastBudgetMaintenanceAt >= BUDGET_MAINTENANCE_INTERVAL_MS)
108
+ this.enforceBudget(now);
109
+ }
110
+ enforceBudget(now = Date.now()) {
111
+ this.lastBudgetMaintenanceAt = now;
112
+ const maxSessions = this.limits.maxSessions ?? DEFAULT_MAX_SESSIONS;
113
+ const maxAgeMs = this.limits.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
114
+ const maxTotalBytes = this.limits.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
115
+ const newest = [...this.metadata.values()].sort((a, b) => b.updatedAt - a.updatedAt);
116
+ const retained = new Set(newest.filter((item, index) => index < maxSessions && now - item.updatedAt <= maxAgeMs).map((item) => item.sessionId));
117
+ let changed = false;
118
+ for (const sessionId of this.metadata.keys()) {
119
+ if (retained.has(sessionId))
120
+ continue;
121
+ this.metadata.delete(sessionId);
122
+ this.pending.delete(sessionId);
123
+ this.removeSessionLogs(sessionId);
124
+ changed = true;
125
+ }
126
+ let files = this.listLogFiles();
127
+ for (const file of files) {
128
+ if (retained.has(file.sessionId))
129
+ continue;
130
+ try {
131
+ fs.rmSync(file.path, { force: true });
132
+ }
133
+ catch { /* best effort */ }
134
+ }
135
+ files = this.listLogFiles();
136
+ let totalBytes = files.reduce((sum, file) => sum + file.size, 0);
137
+ const removalOrder = [
138
+ ...files.filter((file) => file.previous).sort((a, b) => a.mtimeMs - b.mtimeMs),
139
+ ...files.filter((file) => !file.previous).sort((a, b) => a.mtimeMs - b.mtimeMs),
140
+ ];
141
+ for (const file of removalOrder) {
142
+ if (totalBytes <= maxTotalBytes)
143
+ break;
144
+ try {
145
+ fs.rmSync(file.path, { force: true });
146
+ }
147
+ catch {
148
+ continue;
149
+ }
150
+ totalBytes -= file.size;
151
+ if (!file.previous) {
152
+ this.metadata.delete(file.sessionId);
153
+ changed = true;
154
+ }
155
+ }
156
+ if (changed) {
157
+ this.metadataDirty = true;
158
+ this.metadataMustPersist = true;
159
+ this.persistMetadata();
160
+ }
90
161
  }
91
162
  loadMetadata() {
92
163
  try {
@@ -95,6 +166,7 @@ export class SessionSearchStore {
95
166
  if (item && typeof item.sessionId === 'string')
96
167
  this.metadata.set(item.sessionId, item);
97
168
  }
169
+ this.lastMetadataWriteAt = Date.now();
98
170
  }
99
171
  catch { /* first run */ }
100
172
  }
@@ -118,6 +190,64 @@ export class SessionSearchStore {
118
190
  return 0;
119
191
  }
120
192
  }
193
+ metadataChanged(previous, next) {
194
+ return !previous
195
+ || previous.backendSessionId !== next.backendSessionId
196
+ || previous.title !== next.title
197
+ || previous.cwd !== next.cwd
198
+ || previous.agentSlug !== next.agentSlug
199
+ || previous.agentNativeSessionId !== next.agentNativeSessionId;
200
+ }
201
+ persistMetadata() {
202
+ fs.mkdirSync(this.directory, { recursive: true });
203
+ const temporaryPath = `${this.metadataPath()}.${process.pid}.tmp`;
204
+ fs.writeFileSync(temporaryPath, JSON.stringify({ version: 1, sessions: [...this.metadata.values()] }, null, 2), { mode: 0o600 });
205
+ fs.renameSync(temporaryPath, this.metadataPath());
206
+ this.lastMetadataWriteAt = Date.now();
207
+ this.metadataDirty = false;
208
+ this.metadataMustPersist = false;
209
+ }
210
+ removeSessionLogs(sessionId) {
211
+ try {
212
+ fs.rmSync(this.logPath(sessionId), { force: true });
213
+ }
214
+ catch { /* best effort */ }
215
+ try {
216
+ fs.rmSync(this.previousLogPath(sessionId), { force: true });
217
+ }
218
+ catch { /* best effort */ }
219
+ }
220
+ listLogFiles() {
221
+ let names;
222
+ try {
223
+ names = fs.readdirSync(this.directory);
224
+ }
225
+ catch {
226
+ return [];
227
+ }
228
+ return names.flatMap((name) => {
229
+ const match = name.match(/^(.*)\.log(\.1)?$/);
230
+ if (!match)
231
+ return [];
232
+ let sessionId;
233
+ try {
234
+ sessionId = decodeURIComponent(match[1]);
235
+ }
236
+ catch {
237
+ return [];
238
+ }
239
+ const filePath = path.join(this.directory, name);
240
+ try {
241
+ const stat = fs.statSync(filePath);
242
+ return stat.isFile()
243
+ ? [{ path: filePath, sessionId, previous: Boolean(match[2]), size: stat.size, mtimeMs: stat.mtimeMs }]
244
+ : [];
245
+ }
246
+ catch {
247
+ return [];
248
+ }
249
+ });
250
+ }
121
251
  metadataPath() { return path.join(this.directory, 'sessions.json'); }
122
252
  logPath(sessionId) { return path.join(this.directory, `${encodeURIComponent(sessionId)}.log`); }
123
253
  previousLogPath(sessionId) { return `${this.logPath(sessionId)}.1`; }
@@ -23,6 +23,7 @@ import { localAccessManager } from './utils/localAccess.js';
23
23
  import { isAllowedHost, isUpgradeOriginAllowed, validateHostMiddleware, } from './utils/requestSecurity.js';
24
24
  import { getCookieSecurityOptions, setSecureCookieMode } from './utils/cookieSecurity.js';
25
25
  import { requestDeadlineMiddleware } from './utils/requestDeadline.js';
26
+ import { RuntimeMonitor } from './utils/runtimeMonitor.js';
26
27
  import { startOnboardingServer, stopOnboardingServer, getOnboardingServerUrl } from './onboardingServer.js';
27
28
  import { CertificateWatcher } from './certificateWatcher.js';
28
29
  import { startTermdockLogMaintenance, writeDiffTraceLog, writeErrorLog, writeJsonLog, writeTextLog, } from './utils/serverLogger.js';
@@ -67,6 +68,8 @@ function getRouteFamily(pathname) {
67
68
  return 'auth';
68
69
  if (pathname.startsWith('/api/client-log'))
69
70
  return 'client-log';
71
+ if (pathname.startsWith('/api/diagnostics'))
72
+ return 'diagnostics';
70
73
  if (pathname === '/health')
71
74
  return 'health';
72
75
  if (pathname.startsWith('/assets/'))
@@ -288,6 +291,7 @@ export function createApp(options = {}) {
288
291
  return;
289
292
  logged = true;
290
293
  const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
294
+ options.runtimeMonitor?.recordRequest(res.statusCode, durationMs);
291
295
  writeJsonLog('access.log', {
292
296
  event,
293
297
  method: req.method,
@@ -424,6 +428,13 @@ export function createApp(options = {}) {
424
428
  const address = req.socket.remoteAddress ?? '';
425
429
  return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
426
430
  };
431
+ app.get('/api/diagnostics/runtime', requireAuth({ bypass: isTrustedLocalCliRequest }), async (_req, res) => {
432
+ if (!options.runtimeMonitor) {
433
+ res.status(503).json({ error: 'Runtime monitor is not active', code: 'RUNTIME_MONITOR_INACTIVE' });
434
+ return;
435
+ }
436
+ res.json(await options.runtimeMonitor.diagnostics());
437
+ });
427
438
  // 应用鉴权 + CSRF保护(在终端路由之前)
428
439
  // The HTML preview route authenticates itself: document requests use the
429
440
  // session cookie, then get redirected to a short-lived URL token so the
@@ -490,8 +501,20 @@ export function startServer(options = {}) {
490
501
  const stopLogMaintenance = startTermdockLogMaintenance();
491
502
  const port = options.port ?? Number(process.env.PORT || DEFAULT_PORT);
492
503
  const host = options.host ?? (process.env.HOST || DEFAULT_HOST);
493
- const app = createApp({ port: options.onboardingPort ?? port, httpsCaPath: options.httpsCaPath, localApiToken: options.localApiToken });
504
+ const runtimeMonitor = new RuntimeMonitor({
505
+ stateDirectory: path.join(homedir(), '.termdock'),
506
+ historyPath: path.join(homedir(), '.termdock', 'runtime-metrics.log'),
507
+ });
508
+ runtimeMonitor.start();
509
+ const app = createApp({
510
+ port: options.onboardingPort ?? port,
511
+ httpsCaPath: options.httpsCaPath,
512
+ localApiToken: options.localApiToken,
513
+ runtimeMonitor,
514
+ });
494
515
  const { server, scheme } = createServerForApp(app, options);
516
+ server.on('connection', (socket) => runtimeMonitor.trackSocket(socket));
517
+ server.once('close', () => runtimeMonitor.stop());
495
518
  setSecureCookieMode(scheme === 'https');
496
519
  const certWatcher = new CertificateWatcher({
497
520
  enabled: scheme === 'https' && Boolean(options.httpsCertPath && options.httpsKeyPath),
@@ -8,7 +8,7 @@ import watcher from '@parcel/watcher';
8
8
  import busboy from 'busboy';
9
9
  import { pathValidator } from '../utils/pathValidator.js';
10
10
  import { isAuthEnabled, isRequestAuthenticated } from '../utils/authProtection.js';
11
- import { getImageDimensions } from '../utils/imageDimensions.js';
11
+ import { getImageDimensions, parseImageDimensions } from '../utils/imageDimensions.js';
12
12
  import { writeDiffTraceLog, writeErrorLog, writeJsonLog } from '../utils/serverLogger.js';
13
13
  import { clearBranchAuditRecords, clearChangeAuditRecords, listBranchAuditRecords, listChangeAuditRecords, buildChangeAuditFingerprint } from '../utils/changeAuditStore.js';
14
14
  import { getLanIPv4Addresses } from '../utils/localAccess.js';
@@ -17,6 +17,7 @@ import { GitApplyError, HUNK_APPLY_MODES, runGitApply, validateHunkPatch } from
17
17
  import { deleteFilesystemFile } from '../utils/deleteFilesystemFile.js';
18
18
  import { inspectKicadBoardPoint } from '../utils/kicadBoardInspection.js';
19
19
  import { EdaPreviewCache, requestAcceptsEtag } from '../utils/edaPreviewCache.js';
20
+ import { convertHeicPreview, HEIC_PREVIEW_MIME_TYPES, isHeicPreviewPath } from '../utils/heicPreview.js';
20
21
  const router = Router();
21
22
  const MAX_FILE_SIZE = 1024 * 1024; // 1MB
22
23
  const MAX_IMAGE_PREVIEW_SIZE = 20 * 1024 * 1024; // 20MB
@@ -46,6 +47,7 @@ const MAX_NESTED_GIT_REPOS = 32;
46
47
  const NESTED_GIT_DISCOVERY_TIMEOUT_MS = 1_000;
47
48
  const NESTED_GIT_DISCOVERY_CACHE_TTL_MS = 60_000;
48
49
  const FS_ROUTE_TIMEOUT_MS = 6_000;
50
+ const HEIC_PREVIEW_TIMEOUT_MS = 30_000;
49
51
  const EDA_PREVIEW_TIMEOUT_MS = 30_000;
50
52
  const EDA_PREVIEW_CACHE_MAX_BYTES = 64 * 1024 * 1024;
51
53
  const GIT_ROUTE_TIMEOUT_MS = 8_000;
@@ -431,6 +433,7 @@ const IMAGE_MIME_BY_EXT = {
431
433
  '.bmp': 'image/bmp',
432
434
  '.ico': 'image/x-icon',
433
435
  '.svg': 'image/svg+xml',
436
+ ...HEIC_PREVIEW_MIME_TYPES,
434
437
  };
435
438
  function getImageMimeType(filePath) {
436
439
  return IMAGE_MIME_BY_EXT[path.extname(filePath).toLowerCase()] ?? null;
@@ -3024,23 +3027,45 @@ router.get('/blob', async (req, res) => {
3024
3027
  }
3025
3028
  return { resolvedPath, stat, mimeType };
3026
3029
  })(), FS_ROUTE_TIMEOUT_MS, 'Image preview took too long. The file may be on slow storage or currently blocked by another process.', 'FS_BLOB_TIMEOUT');
3027
- res.setHeader('Content-Type', mimeType);
3028
- res.setHeader('Content-Length', stat.size.toString());
3030
+ let responseBody = null;
3031
+ let responseMimeType = mimeType;
3032
+ let responseFilename = path.basename(resolvedPath);
3033
+ if (isHeicPreviewPath(resolvedPath)) {
3034
+ responseBody = await withTimeout(convertHeicPreview(resolvedPath, controller.signal), HEIC_PREVIEW_TIMEOUT_MS, 'HEIC preview conversion took too long.', 'HEIC_PREVIEW_TIMEOUT', () => controller.abort(new OperationTimeoutError('HEIC preview conversion took too long.', 'HEIC_PREVIEW_TIMEOUT')));
3035
+ if (responseBody.length > MAX_IMAGE_PREVIEW_SIZE) {
3036
+ const error = new Error('Converted image is too large to preview');
3037
+ error.code = 'IMAGE_TOO_LARGE';
3038
+ error.status = 413;
3039
+ throw error;
3040
+ }
3041
+ responseMimeType = 'image/jpeg';
3042
+ responseFilename = `${path.basename(resolvedPath, path.extname(resolvedPath))}.jpg`;
3043
+ }
3044
+ const responseSize = responseBody?.length ?? stat.size;
3045
+ res.setHeader('Content-Type', responseMimeType);
3046
+ res.setHeader('Content-Length', responseSize.toString());
3029
3047
  res.setHeader('Last-Modified', stat.mtime.toUTCString());
3030
3048
  res.setHeader('Cache-Control', 'no-store');
3031
3049
  res.setHeader('X-Content-Type-Options', 'nosniff');
3032
- res.setHeader('Content-Disposition', buildContentDisposition('inline', path.basename(resolvedPath)));
3050
+ res.setHeader('Content-Disposition', buildContentDisposition('inline', responseFilename));
3033
3051
  // Expose dimensions so markdown image previews can reserve the final
3034
3052
  // display box before downloading (loading placeholder without a jump).
3035
3053
  // Best-effort: parsing failure just omits the headers.
3036
- const dimensions = await getImageDimensions(resolvedPath, mimeType).catch(() => null);
3054
+ const dimensions = responseBody
3055
+ ? parseImageDimensions(responseBody, responseMimeType)
3056
+ : await getImageDimensions(resolvedPath, responseMimeType).catch(() => null);
3037
3057
  if (dimensions) {
3038
3058
  res.setHeader('X-Image-Width', String(dimensions.width));
3039
3059
  res.setHeader('X-Image-Height', String(dimensions.height));
3040
3060
  }
3061
+ if (responseBody) {
3062
+ logOnce('ok', { path: resolvedPath, bytes: responseSize, extra: { mimeType: responseMimeType, sourceMimeType: mimeType } });
3063
+ res.end(responseBody);
3064
+ return;
3065
+ }
3041
3066
  const stream = fs.createReadStream(resolvedPath);
3042
3067
  controller.signal.addEventListener('abort', () => stream.destroy(controller.signal.reason instanceof Error ? controller.signal.reason : undefined), { once: true });
3043
- res.on('finish', () => logOnce('ok', { path: resolvedPath, bytes: stat.size, extra: { mimeType } }));
3068
+ res.on('finish', () => logOnce('ok', { path: resolvedPath, bytes: stat.size, extra: { mimeType: responseMimeType } }));
3044
3069
  res.on('close', () => {
3045
3070
  if (!res.writableEnded)
3046
3071
  logOnce('error', { path: resolvedPath, bytes: stat.size, code: 'CLIENT_CLOSED', error: 'Client closed image preview request' });
@@ -14,7 +14,7 @@ import { getPtyHostManager } from '../ptyhost/manager.js';
14
14
  import { pathValidator } from '../utils/pathValidator.js';
15
15
  import { TERMINAL, TMUX } from '../config.js';
16
16
  import { localAccessManager } from '../utils/localAccess.js';
17
- import { normalizeLocalAccessName, getLocaleSetting, setLocaleSetting, getContextDraftHeightSetting, setContextDraftHeightSetting, getAutoRenameAgentsSetting, setAutoRenameAgentsSetting, getAutoRenameNamerSetting, setAutoRenameNamerSetting, getAutoRenameModelsSetting, setAutoRenameModelsSetting, getAutoRenameIntervalMinutesSetting, setAutoRenameIntervalMinutesSetting, getAutoRenamePromptPreferenceSetting, setAutoRenamePromptPreferenceSetting, getAutoRenamePromptPayloadCharsSetting, setAutoRenamePromptPayloadCharsSetting, getNewSessionAgentSlugSetting, setNewSessionAgentSlugSetting, } from '../utils/settings.js';
17
+ import { normalizeLocalAccessName, getLocaleSetting, setLocaleSetting, getContextDraftHeightSetting, setContextDraftHeightSetting, getAutoRenameAgentsSetting, setAutoRenameAgentsSetting, getAutoRenameNamerSetting, setAutoRenameNamerSetting, getAutoRenameModelsSetting, setAutoRenameModelsSetting, getAutoRenameIntervalMinutesSetting, setAutoRenameIntervalMinutesSetting, getAutoRenamePromptPreferenceSetting, setAutoRenamePromptPreferenceSetting, getAutoRenamePromptPayloadCharsSetting, setAutoRenamePromptPayloadCharsSetting, getNewSessionAgentSlugSetting, setNewSessionAgentSlugSetting, getRunningSessionButtonEnabledSetting, setRunningSessionButtonEnabledSetting, } from '../utils/settings.js';
18
18
  import { loadContextDraft, saveContextDraft } from '../utils/contextDraft.js';
19
19
  import { getOnboardingServerUrl } from '../onboardingServer.js';
20
20
  import { getFocusSequence, removeClientFocusState, scanFocusTrackingMode, setClientFocusState, } from '../utils/tmuxFocus.js';
@@ -1309,7 +1309,7 @@ function tryDeliverCollaborationInbox(frontendSessionId) {
1309
1309
  || (session.agentSession?.rich && !['idle', 'done'].includes(session.agentSession.status))) {
1310
1310
  return { delivered: [], pending: pending.length };
1311
1311
  }
1312
- writeTerminalInput(session, `${formatCollaborationDelivery(frontendSessionId, pending)}\r`);
1312
+ session.ptyProcess.write(buildBracketedSubmitBytes(formatCollaborationDelivery(frontendSessionId, pending)));
1313
1313
  collaborationStore.markDelivered(pending.map((message) => message.id));
1314
1314
  return { delivered: pending.map((message) => message.id), pending: 0 };
1315
1315
  }
@@ -5185,6 +5185,7 @@ async function getSettingsPayload() {
5185
5185
  autoRenamePromptPreference: getAutoRenamePromptPreferenceSetting(),
5186
5186
  autoRenamePromptPayloadChars: getAutoRenamePromptPayloadCharsSetting(),
5187
5187
  newSessionAgentSlug: getNewSessionAgentSlugSetting(),
5188
+ runningSessionButtonEnabled: getRunningSessionButtonEnabledSetting(),
5188
5189
  localAccess: {
5189
5190
  ...localAccess,
5190
5191
  interfaces,
@@ -5293,6 +5294,9 @@ router.put('/settings', async (req, res) => {
5293
5294
  }
5294
5295
  setNewSessionAgentSlugSetting(slug);
5295
5296
  }
5297
+ if (typeof body.runningSessionButtonEnabled === 'boolean') {
5298
+ setRunningSessionButtonEnabledSetting(body.runningSessionButtonEnabled);
5299
+ }
5296
5300
  if (body.localAccess && typeof body.localAccess === 'object') {
5297
5301
  const localAccessBody = body.localAccess;
5298
5302
  if (localAccessBody.reset === true) {
@@ -0,0 +1,50 @@
1
+ import { Worker } from 'node:worker_threads';
2
+ export const HEIC_PREVIEW_MIME_TYPES = {
3
+ '.heic': 'image/heic',
4
+ '.heif': 'image/heif',
5
+ };
6
+ export function isHeicPreviewPath(filePath) {
7
+ const normalized = filePath.toLowerCase();
8
+ return normalized.endsWith('.heic') || normalized.endsWith('.heif');
9
+ }
10
+ export function convertHeicPreview(filePath, signal) {
11
+ return new Promise((resolve, reject) => {
12
+ const worker = new Worker(new URL('../workers/heicPreviewWorker.js', import.meta.url), {
13
+ workerData: { filePath },
14
+ });
15
+ let settled = false;
16
+ const finish = (callback) => {
17
+ if (settled)
18
+ return;
19
+ settled = true;
20
+ signal?.removeEventListener('abort', abort);
21
+ callback();
22
+ };
23
+ const abort = () => {
24
+ void worker.terminate();
25
+ finish(() => reject(signal?.reason instanceof Error ? signal.reason : new Error('HEIC preview conversion was cancelled')));
26
+ };
27
+ if (signal?.aborted) {
28
+ abort();
29
+ return;
30
+ }
31
+ signal?.addEventListener('abort', abort, { once: true });
32
+ worker.once('message', (message) => {
33
+ if (message.error) {
34
+ finish(() => reject(new Error(message.error)));
35
+ return;
36
+ }
37
+ const { output } = message;
38
+ if (!output) {
39
+ finish(() => reject(new Error('HEIC preview worker returned no image data')));
40
+ return;
41
+ }
42
+ finish(() => resolve(Buffer.from(output)));
43
+ });
44
+ worker.once('error', (error) => finish(() => reject(error)));
45
+ worker.once('exit', (code) => {
46
+ if (code !== 0)
47
+ finish(() => reject(new Error(`HEIC preview worker exited with code ${code}`)));
48
+ });
49
+ });
50
+ }