pinokiod 5.1.35 → 5.1.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/kernel/git.js CHANGED
@@ -84,6 +84,17 @@ class Git {
84
84
  const offM = pad2(abs % 60)
85
85
  return `${y}-${m}-${day}T${hh}:${mm}:${ss}${sign}${offH}:${offM}`
86
86
  }
87
+ normalizeRepoPath(rawPath) {
88
+ if (typeof rawPath !== "string") return "."
89
+ let value = rawPath.trim()
90
+ if (!value) return "."
91
+ value = value.replace(/\\/g, "/").replace(/\/{2,}/g, "/")
92
+ if (value === "." || value === "./") return "."
93
+ if (value.startsWith("./")) {
94
+ value = value.slice(2)
95
+ }
96
+ return value || "."
97
+ }
87
98
  upsertCommitMeta(repoUrlNorm, sha, meta) {
88
99
  if (!repoUrlNorm || typeof repoUrlNorm !== "string") return false
89
100
  if (!this.isCommitSha(sha)) return false
@@ -231,7 +242,7 @@ class Git {
231
242
  const repos = []
232
243
  for (const repo of rawRepos) {
233
244
  if (!repo) continue
234
- const pathVal = typeof repo.path === "string" && repo.path.length > 0 ? repo.path : "."
245
+ const pathVal = this.normalizeRepoPath(repo.path)
235
246
  let remote = typeof repo.remote === "string" && repo.remote.length > 0 ? repo.remote : null
236
247
  if (!remote) remote = typeof repo.repo === "string" && repo.repo.length > 0 ? repo.repo : null
237
248
  const commit = typeof repo.commit === "string" && repo.commit.length > 0 ? repo.commit : null
@@ -559,6 +570,40 @@ class Git {
559
570
  await this.saveManifest()
560
571
  return true
561
572
  }
573
+ async deleteCheckpoint(remoteKey, checkpointId) {
574
+ if (!remoteKey || checkpointId == null) return { ok: false, error: "invalid" }
575
+ const apps = this.apps()
576
+ const entry = apps[remoteKey]
577
+ if (!entry || !Array.isArray(entry.checkpoints)) return { ok: false, error: "not_found" }
578
+ const idStr = String(checkpointId)
579
+ const idx = entry.checkpoints.findIndex((c) => c && String(c.id) === idStr)
580
+ if (idx < 0) return { ok: false, error: "not_found" }
581
+ const record = entry.checkpoints[idx]
582
+ entry.checkpoints.splice(idx, 1)
583
+ await this.saveManifest()
584
+ const hash = record && record.hash ? String(record.hash) : null
585
+ let fileDeleted = false
586
+ if (hash) {
587
+ let stillUsed = false
588
+ for (const entry of Object.values(apps)) {
589
+ if (!entry || !Array.isArray(entry.checkpoints)) continue
590
+ if (entry.checkpoints.some((c) => c && String(c.hash) === hash)) {
591
+ stillUsed = true
592
+ break
593
+ }
594
+ }
595
+ if (!stillUsed) {
596
+ const filePath = this.checkpointFilePath(hash)
597
+ if (filePath) {
598
+ try {
599
+ await fs.promises.rm(filePath, { force: true })
600
+ fileDeleted = true
601
+ } catch (_) {}
602
+ }
603
+ }
604
+ }
605
+ return { ok: true, hash, fileDeleted }
606
+ }
562
607
  async logCheckpointRestore(event) {
563
608
  const logEntry = {
564
609
  ts: Date.now(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinokiod",
3
- "version": "5.1.35",
3
+ "version": "5.1.38",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/server/index.js CHANGED
@@ -5205,6 +5205,38 @@ class Server {
5205
5205
  }
5206
5206
  res.json({ ok: true, redirect: `/p/${encodeURIComponent(folder)}` })
5207
5207
  }))
5208
+ this.app.post("/checkpoints/delete", ex(async (req, res) => {
5209
+ const remoteRaw = typeof req.body.remote === 'string'
5210
+ ? req.body.remote.trim()
5211
+ : (typeof req.query.remote === 'string' ? req.query.remote.trim() : '')
5212
+ const remoteKeyRaw = typeof req.body.remoteKey === 'string'
5213
+ ? req.body.remoteKey.trim()
5214
+ : (typeof req.query.remoteKey === 'string' ? req.query.remoteKey.trim() : '')
5215
+ const snapshotRaw = Object.prototype.hasOwnProperty.call(req.body || {}, "snapshotId")
5216
+ ? req.body.snapshotId
5217
+ : (Object.prototype.hasOwnProperty.call(req.query || {}, "snapshotId") ? req.query.snapshotId : "")
5218
+ const snapshotId = snapshotRaw === 'latest'
5219
+ ? ''
5220
+ : (snapshotRaw == null ? '' : String(snapshotRaw))
5221
+ const remoteKey = remoteKeyRaw || (remoteRaw ? this.kernel.git.normalizeRemote(remoteRaw) : '')
5222
+ if (!snapshotId || !remoteKey) {
5223
+ res.status(400).json({ ok: false, error: "Missing parameters" })
5224
+ return
5225
+ }
5226
+ const result = await this.kernel.git.deleteCheckpoint(remoteKey, snapshotId)
5227
+ if (!result || !result.ok) {
5228
+ res.status(404).json({ ok: false, error: "Snapshot not found" })
5229
+ return
5230
+ }
5231
+ res.json({
5232
+ ok: true,
5233
+ deleted: {
5234
+ id: snapshotId,
5235
+ hash: result.hash || null,
5236
+ fileDeleted: !!result.fileDeleted
5237
+ }
5238
+ })
5239
+ }))
5208
5240
  this.app.get("/checkpoints/restore/:workspace/:snapshotId", ex(async (req, res) => {
5209
5241
  const workspace = typeof req.params.workspace === 'string' ? req.params.workspace : ''
5210
5242
  const snapshotId = req.params.snapshotId
@@ -8652,6 +8684,56 @@ class Server {
8652
8684
  }))
8653
8685
 
8654
8686
 
8687
+ this.app.get("/info/apps", ex(async (req, res) => {
8688
+ const apps = []
8689
+ try {
8690
+ const apipath = this.kernel.path("api")
8691
+ const entries = await fs.promises.readdir(apipath, { withFileTypes: true })
8692
+ for (const entry of entries) {
8693
+ let type
8694
+ try {
8695
+ type = await Util.file_type(apipath, entry)
8696
+ } catch (typeErr) {
8697
+ console.warn('Failed to inspect api entry', entry.name, typeErr)
8698
+ continue
8699
+ }
8700
+ if (!type || !type.directory) {
8701
+ continue
8702
+ }
8703
+ try {
8704
+ const meta = await this.kernel.api.meta(entry.name)
8705
+ apps.push({
8706
+ name: entry.name,
8707
+ title: meta && meta.title ? meta.title : entry.name,
8708
+ description: meta && meta.description ? meta.description : '',
8709
+ icon: meta && meta.icon ? meta.icon : "/pinokio-black.png"
8710
+ })
8711
+ } catch (metaError) {
8712
+ console.warn('Failed to load app metadata', entry.name, metaError)
8713
+ apps.push({
8714
+ name: entry.name,
8715
+ title: entry.name,
8716
+ description: '',
8717
+ icon: "/pinokio-black.png"
8718
+ })
8719
+ }
8720
+ }
8721
+ } catch (enumerationError) {
8722
+ console.warn('Failed to enumerate api apps for url dropdown', enumerationError)
8723
+ }
8724
+
8725
+ apps.sort((a, b) => {
8726
+ const at = (a.title || a.name || '').toLowerCase()
8727
+ const bt = (b.title || b.name || '').toLowerCase()
8728
+ if (at < bt) return -1
8729
+ if (at > bt) return 1
8730
+ return (a.name || '').localeCompare(b.name || '')
8731
+ })
8732
+
8733
+ res.json({ apps })
8734
+ }))
8735
+
8736
+
8655
8737
  this.app.get("/info/procs", ex(async (req, res) => {
8656
8738
  await this.kernel.processes.refresh()
8657
8739
 
@@ -25,8 +25,25 @@ document.addEventListener("DOMContentLoaded", () => {
25
25
  return;
26
26
  }
27
27
 
28
+ const homeIcon = homeLink ? homeLink.querySelector("img.icon") : null;
29
+ const ensureHomeExpandIcon = () => {
30
+ if (!homeLink || !homeIcon) {
31
+ return null;
32
+ }
33
+ let icon = homeLink.querySelector(".home-expand-icon");
34
+ if (!icon) {
35
+ icon = document.createElement("i");
36
+ icon.className = "fa-solid fa-expand home-expand-icon";
37
+ icon.setAttribute("aria-hidden", "true");
38
+ homeLink.appendChild(icon);
39
+ }
40
+ return icon;
41
+ };
42
+ ensureHomeExpandIcon();
43
+
28
44
  // Helper functions used during initial restore must be defined before use
29
- const MIN_MARGIN = 8;
45
+ const MIN_MARGIN = 0;
46
+ const LEGACY_MARGIN = 8;
30
47
 
31
48
  function clampPosition(left, top, sizeOverride) {
32
49
  const rect = header.getBoundingClientRect();
@@ -168,19 +185,24 @@ document.addEventListener("DOMContentLoaded", () => {
168
185
  // Restore persisted or respect DOM state on load (per path, per session)
169
186
  const persisted = readPersisted();
170
187
  const restoreFromStorage = !!(persisted && persisted.minimized);
188
+ const hasStoredPosition = !!(persisted && Number.isFinite(persisted.left) && Number.isFinite(persisted.top));
189
+ const isLegacyDefault = hasStoredPosition
190
+ && Math.abs(persisted.left - LEGACY_MARGIN) < 0.5
191
+ && Math.abs(persisted.top - LEGACY_MARGIN) < 0.5;
192
+ const useStoredPosition = restoreFromStorage && hasStoredPosition && !isLegacyDefault;
171
193
  const domIsMinimized = header.classList.contains("minimized");
172
194
  if (restoreFromStorage || domIsMinimized) {
173
195
  header.classList.add("minimized");
174
196
  // Use minimized size for clamping/positioning
175
197
  const size = measureRect((clone) => { clone.classList.add("minimized"); });
176
- const fallbackLeft = Math.max(MIN_MARGIN, window.innerWidth - size.width - MIN_MARGIN);
177
- const fallbackTop = Math.max(MIN_MARGIN, window.innerHeight - size.height - MIN_MARGIN);
178
- const left = restoreFromStorage && Number.isFinite(persisted.left) ? persisted.left : fallbackLeft;
179
- const top = restoreFromStorage && Number.isFinite(persisted.top) ? persisted.top : fallbackTop;
198
+ const fallbackLeft = MIN_MARGIN;
199
+ const fallbackTop = MIN_MARGIN;
200
+ const left = useStoredPosition ? persisted.left : fallbackLeft;
201
+ const top = useStoredPosition ? persisted.top : fallbackTop;
180
202
  const clamped = clampPosition(left, top, size);
181
203
  state.lastLeft = clamped.left;
182
204
  state.lastTop = clamped.top;
183
- state.hasCustomPosition = restoreFromStorage;
205
+ state.hasCustomPosition = useStoredPosition;
184
206
  state.minimized = true;
185
207
  // Apply immediately and once after layout settles
186
208
  applyPosition(clamped.left, clamped.top);
@@ -234,8 +256,8 @@ document.addEventListener("DOMContentLoaded", () => {
234
256
  clone.classList.add("minimized");
235
257
  });
236
258
 
237
- const defaultLeft = Math.max(MIN_MARGIN, window.innerWidth - minimizedSize.width - MIN_MARGIN);
238
- const defaultTop = Math.max(MIN_MARGIN, window.innerHeight - minimizedSize.height - MIN_MARGIN);
259
+ const defaultLeft = MIN_MARGIN;
260
+ const defaultTop = MIN_MARGIN;
239
261
  const targetLeft = state.hasCustomPosition ? state.lastLeft : defaultLeft;
240
262
  const targetTop = state.hasCustomPosition ? state.lastTop : defaultTop;
241
263
 
@@ -2978,7 +2978,6 @@ header.navheader.minimized {
2978
2978
  height: auto;
2979
2979
  max-height: none;
2980
2980
  padding: 4px 8px;
2981
- border-radius: 12px;
2982
2981
  background: var(--light-nav-bg);
2983
2982
  box-shadow: 0 8px 18px rgba(0, 0, 0, 0.14), 0 2px 6px rgba(0, 0, 0, 0.08);
2984
2983
  display: inline-flex;
@@ -3026,11 +3025,11 @@ header.navheader .header-drag-handle::before {
3026
3025
  height: 18px;
3027
3026
  border-radius: 3px;
3028
3027
  opacity: 0.45;
3029
- background-image: repeating-linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0 1px, transparent 1px 4px);
3028
+ background-image: repeating-linear-gradient(to bottom, rgba(0, 0, 0, 0.9) 0 1px, transparent 1px 4px);
3030
3029
  transition: opacity 0.2s ease;
3031
3030
  }
3032
3031
  body.dark header.navheader .header-drag-handle::before {
3033
- background-image: repeating-linear-gradient(to bottom, rgba(255, 255, 255, 0.55) 0 1px, transparent 1px 4px);
3032
+ background-image: repeating-linear-gradient(to bottom, rgba(255, 255, 255, 0.9) 0 1px, transparent 1px 4px);
3034
3033
  opacity: 0.35;
3035
3034
  }
3036
3035
  header.navheader.minimized .header-drag-handle {
@@ -3052,9 +3051,23 @@ header.navheader.minimized .home {
3052
3051
  padding: 0;
3053
3052
  }
3054
3053
  header.navheader.minimized .home .icon {
3054
+ display: none;
3055
3055
  width: 24px;
3056
3056
  height: 24px;
3057
3057
  }
3058
+ header.navheader .home .home-expand-icon {
3059
+ display: none;
3060
+ }
3061
+ header.navheader.minimized .home .home-expand-icon {
3062
+ display: inline-flex;
3063
+ width: 24px;
3064
+ height: 24px;
3065
+ align-items: center;
3066
+ justify-content: center;
3067
+ font-size: 16px;
3068
+ line-height: 1;
3069
+ color: inherit;
3070
+ }
3058
3071
 
3059
3072
 
3060
3073
  header.navheader.transitioning {
@@ -69,6 +69,7 @@ function initUrlDropdown(config = {}) {
69
69
  clearBehavior: config.clearBehavior || 'empty', // 'empty' or 'restore'
70
70
  defaultValue: config.defaultValue || '',
71
71
  apiEndpoint: config.apiEndpoint || '/info/procs',
72
+ appsEndpoint: config.appsEndpoint || '/info/apps',
72
73
  ...config
73
74
  };
74
75
 
@@ -218,9 +219,113 @@ function initUrlDropdown(config = {}) {
218
219
  return `<div class="url-mode-buttons" role="group" aria-label="Project views">${buttonsHtml}</div>`;
219
220
  };
220
221
 
222
+ const getAppBasePath = (app) => {
223
+ if (!app || !app.name) return '';
224
+ return `/p/${encodeURIComponent(app.name)}`;
225
+ };
226
+
227
+ const getAppDisplayTitle = (app) => {
228
+ if (!app) return 'Untitled app';
229
+ return app.title || app.name || 'Untitled app';
230
+ };
231
+
232
+ const buildAppProjectContext = (app, origin) => {
233
+ const basePath = getAppBasePath(app);
234
+ if (!basePath) return null;
235
+ return {
236
+ origin: origin || '',
237
+ project: app.name,
238
+ basePath,
239
+ currentMode: 'run'
240
+ };
241
+ };
242
+
243
+ const buildAppsSectionHtml = (apps, {
244
+ includeCurrentTab = true,
245
+ currentUrl = '',
246
+ currentTitle = 'Current tab',
247
+ currentProject = null,
248
+ origin = ''
249
+ } = {}) => {
250
+ const entries = [];
251
+
252
+ if (includeCurrentTab && currentUrl) {
253
+ const schemeLabel = currentUrl.startsWith('https://') ? 'HTTPS' : 'HTTP';
254
+ const currentPathLabel = currentProject ? currentProject.basePath : formatUrlLabel(currentUrl) || currentUrl;
255
+ const projectButtons = currentProject ? buildProjectModeButtons(currentProject) : '';
256
+ entries.push(`
257
+ <div class="url-dropdown-item${currentProject ? ' non-selectable current-project' : ''}" data-url="${escapeAttribute(currentUrl)}" data-host-type="current">
258
+ <div class="url-dropdown-name">
259
+ <span>
260
+ <i class="fa-solid fa-clone"></i>
261
+ ${escapeHtml(currentTitle)}
262
+ </span>
263
+ </div>
264
+ ${currentProject ? `
265
+ <div class="url-dropdown-url">
266
+ <span class="url-scheme ${schemeLabel === 'HTTPS' ? 'https' : 'http'}">${schemeLabel}</span>
267
+ <span class="url-address">${escapeHtml(currentPathLabel)}</span>
268
+ </div>
269
+ ${projectButtons}
270
+ ` : `
271
+ <div class="url-dropdown-url">
272
+ <span class="url-scheme ${schemeLabel === 'HTTPS' ? 'https' : 'http'}">${schemeLabel}</span>
273
+ <span class="url-address">${escapeHtml(currentPathLabel)}</span>
274
+ </div>
275
+ `}
276
+ </div>
277
+ `);
278
+ }
279
+
280
+ (Array.isArray(apps) ? apps : []).forEach((app) => {
281
+ if (!app || !app.name) return;
282
+ if (currentProject && app.name === currentProject.project) {
283
+ return;
284
+ }
285
+ const projectCtx = buildAppProjectContext(app, origin);
286
+ if (!projectCtx) return;
287
+ const displayUrl = projectCtx.origin ? `${projectCtx.origin}${projectCtx.basePath}` : projectCtx.basePath;
288
+ const schemeLabel = displayUrl.startsWith('https://') ? 'HTTPS' : 'HTTP';
289
+ const projectButtons = buildProjectModeButtons(projectCtx);
290
+ const displayTitle = getAppDisplayTitle(app);
291
+ entries.push(`
292
+ <div class="url-dropdown-item non-selectable current-project" data-url="${escapeAttribute(displayUrl)}" data-host-type="current">
293
+ <div class="url-dropdown-name">
294
+ <span>
295
+ <i class="fa-solid fa-box"></i>
296
+ ${escapeHtml(displayTitle)}
297
+ </span>
298
+ </div>
299
+ <div class="url-dropdown-url">
300
+ <span class="url-scheme ${schemeLabel === 'HTTPS' ? 'https' : 'http'}">${schemeLabel}</span>
301
+ <span class="url-address">${escapeHtml(projectCtx.basePath)}</span>
302
+ </div>
303
+ ${projectButtons}
304
+ </div>
305
+ `);
306
+ });
307
+
308
+ if (entries.length === 0) {
309
+ return '';
310
+ }
311
+
312
+ return `
313
+ <div class="url-dropdown-host-header current-tab">
314
+ <span class="host-name">Apps</span>
315
+ </div>
316
+ ${entries.join('')}
317
+ `;
318
+ };
319
+
221
320
  let isDropdownVisible = false;
222
321
  let allProcesses = []; // Store all processes for filtering
223
322
  let filteredProcesses = []; // Store currently filtered processes
323
+ let allApps = [];
324
+ let filteredApps = [];
325
+ let processesLoaded = false;
326
+ let appsLoaded = false;
327
+ let processesFetchPromise = null;
328
+ let appsFetchPromise = null;
224
329
  let createLauncherModal = null;
225
330
  let pendingCreateDetail = null;
226
331
  let mobileModalKeydownHandler = null;
@@ -266,7 +371,7 @@ function initUrlDropdown(config = {}) {
266
371
  if (!el) {
267
372
  return;
268
373
  }
269
- const val = el.value;
374
+ const val = el.value
270
375
  const type = el.getAttribute("data-host-type");
271
376
  openUrlWithType(val, type);
272
377
  });
@@ -285,28 +390,21 @@ function initUrlDropdown(config = {}) {
285
390
  }
286
391
  }
287
392
 
288
- function showDropdown() {
289
- if (!dropdown || !urlInput) return;
290
- if (isDropdownVisible && allProcesses.length > 0) {
291
- // If dropdown is already visible and we have data, show all initially
292
- showAllProcesses();
293
- return;
393
+ const normalizeAppsResponse = (data) => {
394
+ if (!data) return [];
395
+ if (Array.isArray(data.apps)) return data.apps;
396
+ if (Array.isArray(data)) return data;
397
+ return [];
398
+ };
399
+
400
+ const fetchProcesses = () => {
401
+ if (processesLoaded) {
402
+ return Promise.resolve(allProcesses);
294
403
  }
295
-
296
- isDropdownVisible = true;
297
- dropdown.style.display = 'block';
298
-
299
- // If we already have processes data, show all initially
300
- if (allProcesses.length > 0) {
301
- showAllProcesses();
302
- return;
404
+ if (processesFetchPromise) {
405
+ return processesFetchPromise;
303
406
  }
304
-
305
- // Otherwise, show loading and fetch data
306
- dropdown.innerHTML = '<div class="url-dropdown-loading">Loading running processes...</div>';
307
-
308
- // Fetch processes from API
309
- fetch(options.apiEndpoint)
407
+ processesFetchPromise = fetch(options.apiEndpoint)
310
408
  .then(response => {
311
409
  if (!response.ok) {
312
410
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
@@ -315,18 +413,90 @@ function initUrlDropdown(config = {}) {
315
413
  })
316
414
  .then(data => {
317
415
  allProcesses = data.info || [];
318
- showAllProcesses(); // Show all processes when dropdown first opens
416
+ processesLoaded = true;
417
+ return allProcesses;
319
418
  })
320
419
  .catch(error => {
321
420
  console.error('Failed to fetch processes:', error);
322
- dropdown.innerHTML = '<div class="url-dropdown-empty">Failed to load processes</div>';
323
421
  allProcesses = [];
422
+ processesLoaded = true;
423
+ return allProcesses;
424
+ })
425
+ .finally(() => {
426
+ processesFetchPromise = null;
427
+ });
428
+ return processesFetchPromise;
429
+ };
430
+
431
+ const fetchApps = () => {
432
+ if (appsLoaded) {
433
+ return Promise.resolve(allApps);
434
+ }
435
+ if (appsFetchPromise) {
436
+ return appsFetchPromise;
437
+ }
438
+ appsFetchPromise = fetch(options.appsEndpoint)
439
+ .then(response => {
440
+ if (!response.ok) {
441
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
442
+ }
443
+ return response.json();
444
+ })
445
+ .then(data => {
446
+ allApps = normalizeAppsResponse(data);
447
+ appsLoaded = true;
448
+ return allApps;
449
+ })
450
+ .catch(error => {
451
+ console.error('Failed to fetch apps:', error);
452
+ allApps = [];
453
+ appsLoaded = true;
454
+ return allApps;
455
+ })
456
+ .finally(() => {
457
+ appsFetchPromise = null;
458
+ });
459
+ return appsFetchPromise;
460
+ };
461
+
462
+ function showDropdown() {
463
+ if (!dropdown || !urlInput) return;
464
+ const needsProcessFetch = !processesLoaded;
465
+ const needsAppsFetch = !appsLoaded;
466
+ if (isDropdownVisible && !needsProcessFetch && !needsAppsFetch) {
467
+ // If dropdown is already visible and we have data, show all initially
468
+ showAllProcesses();
469
+ return;
470
+ }
471
+
472
+ isDropdownVisible = true;
473
+ dropdown.style.display = 'block';
474
+
475
+ const hasAnyData = allProcesses.length > 0 || allApps.length > 0;
476
+ if (hasAnyData) {
477
+ showAllProcesses();
478
+ }
479
+
480
+ if (!needsProcessFetch && !needsAppsFetch) {
481
+ return;
482
+ }
483
+
484
+ if (!hasAnyData) {
485
+ dropdown.innerHTML = '<div class="url-dropdown-loading">Loading apps and running processes...</div>';
486
+ }
487
+
488
+ Promise.allSettled([fetchApps(), fetchProcesses()])
489
+ .then(() => {
490
+ if (isDropdownVisible) {
491
+ showAllProcesses();
492
+ }
324
493
  });
325
494
  }
326
495
 
327
496
  function showAllProcesses() {
328
497
  filteredProcesses = allProcesses;
329
- populateDropdown(filteredProcesses);
498
+ filteredApps = allApps;
499
+ populateDropdown(filteredProcesses, filteredApps);
330
500
  }
331
501
 
332
502
  function handleInputChange() {
@@ -339,9 +509,11 @@ function initUrlDropdown(config = {}) {
339
509
  if (urlInput.selectionStart === 0 && urlInput.selectionEnd === urlInput.value.length) {
340
510
  // Text is fully selected, show all processes until user starts typing
341
511
  filteredProcesses = allProcesses;
512
+ filteredApps = allApps;
342
513
  } else if (!query) {
343
514
  // No query, show all processes
344
515
  filteredProcesses = allProcesses;
516
+ filteredApps = allApps;
345
517
  } else {
346
518
  // Filter processes based on name and URL
347
519
  filteredProcesses = allProcesses.filter(process => {
@@ -352,9 +524,19 @@ function initUrlDropdown(config = {}) {
352
524
  const urls = getProcessFilterValues(process);
353
525
  return urls.some((value) => (value || '').toLowerCase().includes(query));
354
526
  });
527
+ filteredApps = allApps.filter(app => {
528
+ const name = (app && app.name ? app.name : '').toLowerCase();
529
+ const title = (app && app.title ? app.title : '').toLowerCase();
530
+ const description = (app && app.description ? app.description : '').toLowerCase();
531
+ if (name.includes(query) || title.includes(query) || description.includes(query)) {
532
+ return true;
533
+ }
534
+ const basePath = getAppBasePath(app);
535
+ return (basePath || '').toLowerCase().includes(query);
536
+ });
355
537
  }
356
538
 
357
- populateDropdown(filteredProcesses);
539
+ populateDropdown(filteredProcesses, filteredApps);
358
540
  }
359
541
 
360
542
  function hideDropdown() {
@@ -526,54 +708,37 @@ function initUrlDropdown(config = {}) {
526
708
  return html;
527
709
  };
528
710
 
529
- const buildDropdownHtml = (processes, { includeCurrentTab = true, inputElement } = {}) => {
711
+ const buildDropdownHtml = (processes, { includeCurrentTab = true, apps = [], inputElement } = {}) => {
530
712
  const currentUrl = typeof window !== 'undefined' ? window.location.href : '';
531
713
  const currentTitle = typeof document !== 'undefined' ? (document.title || 'Current tab') : 'Current tab';
532
714
  const currentProject = parseProjectContext(currentUrl);
715
+ const origin = typeof window !== 'undefined' && window.location ? window.location.origin : '';
533
716
 
534
717
  let html = '';
535
- if (includeCurrentTab && currentUrl) {
536
- const schemeLabel = currentUrl.startsWith('https://') ? 'HTTPS' : 'HTTP';
537
- const currentPathLabel = currentProject ? currentProject.basePath : formatUrlLabel(currentUrl) || currentUrl;
538
- const projectButtons = currentProject ? buildProjectModeButtons(currentProject) : '';
539
- html += `
540
- <div class="url-dropdown-host-header current-tab">
541
- <span class="host-name">Current tab</span>
542
- </div>
543
- <div class="url-dropdown-item${currentProject ? ' non-selectable current-project' : ''}" data-url="${escapeAttribute(currentUrl)}" data-host-type="current">
544
- <div class="url-dropdown-name">
545
- <span>
546
- <i class="fa-solid fa-clone"></i>
547
- ${escapeHtml(currentTitle)}
548
- </span>
549
- </div>
550
- ${currentProject ? `
551
- <div class="url-dropdown-url">
552
- <span class="url-scheme ${schemeLabel === 'HTTPS' ? 'https' : 'http'}">${schemeLabel}</span>
553
- <span class="url-address">${escapeHtml(currentPathLabel)}</span>
554
- </div>
555
- ${projectButtons}
556
- ` : `
557
- <div class="url-dropdown-url">
558
- <span class="url-scheme ${schemeLabel === 'HTTPS' ? 'https' : 'http'}">${schemeLabel}</span>
559
- <span class="url-address">${escapeHtml(currentPathLabel)}</span>
560
- </div>
561
- `}
562
- </div>
563
- `;
718
+ const appsHtml = buildAppsSectionHtml(apps, {
719
+ includeCurrentTab,
720
+ currentUrl,
721
+ currentTitle,
722
+ currentProject,
723
+ origin
724
+ });
725
+ if (appsHtml) {
726
+ html += appsHtml;
564
727
  }
565
728
 
566
- if (!processes || processes.length === 0) {
567
- html += createEmptyStateHtml(getEmptyStateMessage(inputElement));
568
- return html;
729
+ if (processes && processes.length > 0) {
730
+ html += buildHostsHtml(processes);
731
+ }
732
+
733
+ if (!html) {
734
+ html = createEmptyStateHtml(getEmptyStateMessage(inputElement));
569
735
  }
570
736
 
571
- html += buildHostsHtml(processes);
572
737
  return html;
573
738
  };
574
739
 
575
- function populateDropdown(processes) {
576
- dropdown.innerHTML = buildDropdownHtml(processes, { includeCurrentTab: true, inputElement: urlInput });
740
+ function populateDropdown(processes, apps) {
741
+ dropdown.innerHTML = buildDropdownHtml(processes, { includeCurrentTab: true, apps, inputElement: urlInput });
577
742
  attachCreateButtonHandler(dropdown, urlInput);
578
743
  attachUrlItemHandlers(dropdown);
579
744
  }
@@ -758,7 +923,7 @@ function initUrlDropdown(config = {}) {
758
923
  const description = document.createElement('p');
759
924
  description.className = 'url-modal-description';
760
925
  description.id = 'url-modal-description';
761
- description.textContent = 'Enter a local URL or choose from running processes.';
926
+ description.textContent = 'Enter a local URL or choose from apps and running processes.';
762
927
 
763
928
  content.setAttribute('aria-labelledby', heading.id);
764
929
  content.setAttribute('aria-describedby', description.id);
@@ -980,46 +1145,46 @@ function initUrlDropdown(config = {}) {
980
1145
 
981
1146
  const includeCurrent = modalDropdown._includeCurrent !== false;
982
1147
  modalDropdown._includeCurrent = includeCurrent;
1148
+ const needsProcessFetch = !processesLoaded;
1149
+ const needsAppsFetch = !appsLoaded;
983
1150
 
984
- const render = (processes) => {
1151
+ const render = (processes, apps) => {
985
1152
  modalDropdown.innerHTML = buildDropdownHtml(processes, {
986
1153
  includeCurrentTab: includeCurrent,
1154
+ apps,
987
1155
  inputElement: modalDropdown.parentElement.querySelector('.url-modal-input')
988
1156
  });
989
1157
  attachCreateButtonHandler(modalDropdown, modalDropdown.parentElement.querySelector('.url-modal-input'));
990
1158
  attachUrlItemHandlers(modalDropdown, { onSelect: handleModalSelection });
991
1159
  };
992
1160
 
993
- if (allProcesses.length > 0) {
994
- render(allProcesses);
1161
+ const hasAnyData = allProcesses.length > 0 || allApps.length > 0;
1162
+ if (hasAnyData) {
1163
+ render(allProcesses, allApps);
1164
+ }
1165
+
1166
+ if (!needsProcessFetch && !needsAppsFetch) {
995
1167
  return;
996
1168
  }
997
1169
 
998
- modalDropdown.innerHTML = '<div class="url-dropdown-loading">Loading running processes...</div>';
1170
+ if (!hasAnyData) {
1171
+ modalDropdown.innerHTML = '<div class="url-dropdown-loading">Loading apps and running processes...</div>';
1172
+ }
999
1173
 
1000
- fetch(options.apiEndpoint)
1001
- .then(response => {
1002
- if (!response.ok) {
1003
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1004
- }
1005
- return response.json();
1006
- })
1007
- .then(data => {
1008
- allProcesses = data.info || [];
1009
- render(allProcesses);
1010
- })
1011
- .catch(error => {
1012
- console.error('Failed to fetch processes:', error);
1013
- modalDropdown.innerHTML = '<div class="url-dropdown-empty">Failed to load processes</div>';
1174
+ Promise.allSettled([fetchApps(), fetchProcesses()])
1175
+ .then(() => {
1176
+ render(allProcesses, allApps);
1014
1177
  });
1015
1178
  }
1016
1179
 
1017
1180
  function handleModalInputChange(modalInput, modalDropdown) {
1018
1181
  const query = modalInput.value.toLowerCase().trim();
1019
1182
  let filtered = allProcesses;
1183
+ let filteredAppList = allApps;
1020
1184
 
1021
1185
  if (modalInput.selectionStart === 0 && modalInput.selectionEnd === modalInput.value.length) {
1022
1186
  filtered = allProcesses;
1187
+ filteredAppList = allApps;
1023
1188
  } else if (query) {
1024
1189
  filtered = allProcesses.filter(process => {
1025
1190
  const name = (process.name || '').toLowerCase();
@@ -1029,18 +1194,29 @@ function initUrlDropdown(config = {}) {
1029
1194
  const urls = getProcessFilterValues(process);
1030
1195
  return urls.some((value) => (value || '').toLowerCase().includes(query));
1031
1196
  });
1197
+ filteredAppList = allApps.filter(app => {
1198
+ const name = (app && app.name ? app.name : '').toLowerCase();
1199
+ const title = (app && app.title ? app.title : '').toLowerCase();
1200
+ const description = (app && app.description ? app.description : '').toLowerCase();
1201
+ if (name.includes(query) || title.includes(query) || description.includes(query)) {
1202
+ return true;
1203
+ }
1204
+ const basePath = getAppBasePath(app);
1205
+ return (basePath || '').toLowerCase().includes(query);
1206
+ });
1032
1207
  }
1033
1208
 
1034
- populateModalDropdown(filtered, modalDropdown);
1209
+ populateModalDropdown(filtered, filteredAppList, modalDropdown);
1035
1210
  }
1036
1211
 
1037
- function populateModalDropdown(processes, modalDropdown) {
1212
+ function populateModalDropdown(processes, apps, modalDropdown) {
1038
1213
  const modalInput = modalDropdown.parentElement.querySelector('.url-modal-input');
1039
1214
  const overlayRefs = getModalRefs();
1040
1215
  const includeCurrent = overlayRefs?.includeCurrent !== false;
1041
1216
 
1042
1217
  modalDropdown.innerHTML = buildDropdownHtml(processes, {
1043
1218
  includeCurrentTab: includeCurrent,
1219
+ apps,
1044
1220
  inputElement: modalInput
1045
1221
  });
1046
1222
 
@@ -1061,6 +1237,12 @@ function initUrlDropdown(config = {}) {
1061
1237
  closeMobileModal,
1062
1238
  refresh: function() {
1063
1239
  allProcesses = []; // Clear cache to force refetch
1240
+ allApps = [];
1241
+ filteredApps = [];
1242
+ processesLoaded = false;
1243
+ appsLoaded = false;
1244
+ processesFetchPromise = null;
1245
+ appsFetchPromise = null;
1064
1246
  if (isDropdownVisible) {
1065
1247
  showDropdown();
1066
1248
  }
@@ -1069,7 +1251,7 @@ function initUrlDropdown(config = {}) {
1069
1251
  openSplitModal: function(modalOptions = {}) {
1070
1252
  return showMobileModal({
1071
1253
  title: modalOptions.title || 'Split View',
1072
- description: modalOptions.description || 'Choose a running process or use the current tab URL for the new pane.',
1254
+ description: modalOptions.description || 'Choose an app, a running process, or use the current tab URL for the new pane.',
1073
1255
  confirmLabel: modalOptions.confirmLabel || 'Split',
1074
1256
  includeCurrent: modalOptions.includeCurrent !== false,
1075
1257
  awaitSelection: true,
@@ -1087,6 +1269,12 @@ function initUrlDropdown(config = {}) {
1087
1269
  closeMobileModal({ suppressResolve: true });
1088
1270
  allProcesses = [];
1089
1271
  filteredProcesses = [];
1272
+ allApps = [];
1273
+ filteredApps = [];
1274
+ processesLoaded = false;
1275
+ appsLoaded = false;
1276
+ processesFetchPromise = null;
1277
+ appsFetchPromise = null;
1090
1278
  if (fallbackElements.form && fallbackElements.form.parentElement) {
1091
1279
  fallbackElements.form.parentElement.removeChild(fallbackElements.form);
1092
1280
  }
@@ -1110,7 +1298,7 @@ function initUrlDropdown(config = {}) {
1110
1298
 
1111
1299
  function getEmptyStateMessage(inputElement) {
1112
1300
  const rawValue = inputElement.value.trim();
1113
- return rawValue ? `No processes match "${rawValue}"` : 'No running processes found';
1301
+ return rawValue ? `No apps or processes match "${rawValue}"` : 'No apps or running processes found';
1114
1302
  }
1115
1303
 
1116
1304
  function createEmptyStateHtml(message) {
@@ -225,7 +225,7 @@ body.dark .appcanvas_filler {
225
225
  flex: 0 0 0px;
226
226
  align-items: stretch;
227
227
  justify-content: center;
228
- cursor: grab;
228
+ cursor: col-resize;
229
229
  touch-action: none;
230
230
  }
231
231
  .appcanvas.vertical .appcanvas-resizer:hover::before {
@@ -1277,6 +1277,8 @@ body.dark .disk-usage {
1277
1277
  color: white;
1278
1278
  }
1279
1279
  .disk-usage {
1280
+ display: block;
1281
+ cursor: pointer;
1280
1282
  border-right: 1px solid rgba(0,0,0,0.1);
1281
1283
  font-weight: bold;
1282
1284
  color: black;
@@ -3482,6 +3484,7 @@ body.dark .snapshot-footer-input input {
3482
3484
  <div><%=config.title%></div>
3483
3485
  <% } %>
3484
3486
  </div>
3487
+ <span class="disk-usage tab-metric__value" data-path="/" data-filepath="<%=path%>">--</span>
3485
3488
  <div class='m n system' data-type="n">
3486
3489
  <%if (type==='browse') { %>
3487
3490
  <a id='devtab' data-mode="refresh" target="<%=dev_link%>" href="<%=dev_link%>" class="btn frame-link selected" data-index="10">
@@ -3551,7 +3554,6 @@ body.dark .snapshot-footer-input input {
3551
3554
  </div>
3552
3555
  </div>
3553
3556
  -->
3554
- <span class="disk-usage tab-metric__value" data-path="/">--</span>
3555
3557
  <div class='fs-status-dropdown fs-open-explorer'>
3556
3558
  <button class='fs-status-btn' data-filepath="<%=path%>" type='button'>
3557
3559
  <span class='fs-status-label'>
@@ -3608,7 +3610,7 @@ body.dark .snapshot-footer-input input {
3608
3610
  </div>
3609
3611
  </div>
3610
3612
  -->
3611
- <span class="disk-usage tab-metric__value" data-path="/">--</span>
3613
+ <span class="disk-usage tab-metric__value" data-path="/" data-filepath="<%=path%>">--</span>
3612
3614
  <div class='fs-status-dropdown fs-open-explorer'>
3613
3615
  <button class='fs-status-btn' data-filepath="<%=path%>" type='button'>
3614
3616
  <span class='fs-status-label'>
@@ -229,6 +229,26 @@ body.dark .snapshot-card {
229
229
  font-size: 14px;
230
230
  font-weight: 600;
231
231
  }
232
+ .snapshot-actions .snapshot-delete {
233
+ padding: 8px 16px;
234
+ border-radius: 6px;
235
+ font-size: 14px;
236
+ font-weight: 600;
237
+ }
238
+ .url-modal-button.danger {
239
+ background: rgba(239, 68, 68, 0.12);
240
+ border: 1px solid rgba(239, 68, 68, 0.4);
241
+ color: rgba(185, 28, 28, 0.95);
242
+ }
243
+ .url-modal-button.danger:hover {
244
+ background: rgba(239, 68, 68, 0.2);
245
+ box-shadow: 0 12px 28px rgba(239, 68, 68, 0.16);
246
+ }
247
+ body.dark .url-modal-button.danger {
248
+ background: rgba(239, 68, 68, 0.16);
249
+ border-color: rgba(248, 113, 113, 0.5);
250
+ color: rgba(254, 202, 202, 0.95);
251
+ }
232
252
  .snapshot-option input[type="radio"] {
233
253
  margin-top: 6px;
234
254
  }
@@ -505,6 +525,7 @@ document.addEventListener("DOMContentLoaded", () => {
505
525
  <div class="snapshot-title"><i class="fa-regular fa-clock"></i> ${label}</div>
506
526
  <div class="snapshot-actions">
507
527
  ${publishMarkup}
528
+ <button type="button" class="url-modal-button danger snapshot-delete" data-snapshot-id="${snap.id}">Delete</button>
508
529
  <button type="button" class="url-modal-button confirm snapshot-install" data-snapshot-id="${snap.id}">Install</button>
509
530
  </div>
510
531
  </div>
@@ -572,15 +593,22 @@ document.addEventListener("DOMContentLoaded", () => {
572
593
  const hint = container.querySelector("#folder-hint")
573
594
  const closeBtn = container.querySelector('.url-modal-close')
574
595
  const installButtons = Array.from(container.querySelectorAll(".snapshot-install"))
596
+ const deleteButtons = Array.from(container.querySelectorAll(".snapshot-delete"))
597
+ const publishButtons = Array.from(container.querySelectorAll(".snapshot-publish"))
598
+ const actionButtons = installButtons.concat(deleteButtons, publishButtons)
575
599
  const loadingOverlay = container.querySelector("#backup-loading")
600
+ const loadingText = loadingOverlay ? loadingOverlay.querySelector(".backup-loading-text") : null
601
+ const defaultLoadingText = loadingText ? loadingText.textContent : ""
576
602
 
577
- const setLoading = (isLoading) => {
603
+ const setLoading = (isLoading, message) => {
578
604
  if (loadingOverlay) {
579
605
  loadingOverlay.classList.toggle("hidden", !isLoading)
606
+ if (loadingText) {
607
+ loadingText.textContent = isLoading ? (message || defaultLoadingText) : defaultLoadingText
608
+ }
580
609
  }
581
- installButtons.forEach((btn) => {
610
+ actionButtons.forEach((btn) => {
582
611
  btn.disabled = isLoading
583
- if (isLoading) btn.textContent = "Installing..."
584
612
  })
585
613
  if (closeBtn) closeBtn.disabled = isLoading
586
614
  }
@@ -673,7 +701,7 @@ document.addEventListener("DOMContentLoaded", () => {
673
701
  installBtn.disabled = true
674
702
  installBtn.textContent = "Installing..."
675
703
  }
676
- setLoading(true)
704
+ setLoading(true, "Installing...")
677
705
  try {
678
706
  const res = await fetch("/checkpoints/install", {
679
707
  method: "POST",
@@ -712,6 +740,97 @@ document.addEventListener("DOMContentLoaded", () => {
712
740
  }
713
741
  }
714
742
 
743
+ const updateItemRow = (currentItem) => {
744
+ if (!currentItem || !currentItem.remoteKey) return
745
+ const row = document.querySelector(`.backup-row[data-remote-key="${currentItem.remoteKey}"]`)
746
+ if (!row) return
747
+ const snapshots = Array.isArray(currentItem.snapshots) ? currentItem.snapshots : []
748
+ const badgeRow = row.querySelector(".description.backup-meta")
749
+ if (badgeRow) {
750
+ const badges = badgeRow.querySelectorAll(".badge")
751
+ if (badges.length > 1) {
752
+ const count = snapshots.length
753
+ badges[1].textContent = count
754
+ ? `${count} snapshot${count === 1 ? '' : 's'}`
755
+ : "No snapshots yet"
756
+ }
757
+ }
758
+ const latestRow = Array.from(row.querySelectorAll(".description.backup-meta"))
759
+ .find((el) => el.querySelector(".fa-regular.fa-clock"))
760
+ if (latestRow) {
761
+ if (!snapshots.length) {
762
+ latestRow.remove()
763
+ } else {
764
+ const span = latestRow.querySelector("span")
765
+ if (span) span.textContent = `Latest checkpoint: ${new Date(snapshots[0].id).toLocaleString()}`
766
+ }
767
+ }
768
+ }
769
+
770
+ const handleDelete = async (snapshotId, btn) => {
771
+ const idStr = snapshotId != null ? String(snapshotId).trim() : ""
772
+ if (!idStr || idStr === "latest") return
773
+ const installed = installedBySnapshot[idStr] || []
774
+ const snapInfo = Array.isArray(item.snapshots)
775
+ ? item.snapshots.find((snap) => snap && String(snap.id) === idStr)
776
+ : null
777
+ const confirmLines = [
778
+ "Delete this checkpoint locally?",
779
+ "This removes the local snapshot file and manifest entry."
780
+ ]
781
+ if (installed.length) {
782
+ confirmLines.push(`Installed in: ${installed.join(", ")}`)
783
+ }
784
+ if (snapInfo && snapInfo.sync && snapInfo.sync.status === "published") {
785
+ confirmLines.push("Cloud copy will remain in the registry.")
786
+ }
787
+ const confirmed = confirm(confirmLines.join("\n"))
788
+ if (!confirmed) return
789
+ if (btn) {
790
+ btn.disabled = true
791
+ btn.textContent = "Deleting..."
792
+ }
793
+ setLoading(true, "Deleting...")
794
+ try {
795
+ const res = await fetch("/checkpoints/delete", {
796
+ method: "POST",
797
+ headers: {
798
+ "Content-Type": "application/json",
799
+ "Accept": "application/json"
800
+ },
801
+ body: JSON.stringify({
802
+ remote: item.remoteUrl,
803
+ snapshotId: idStr
804
+ })
805
+ })
806
+ const payload = res && res.ok ? await res.json() : null
807
+ if (payload && payload.ok) {
808
+ const option = btn ? btn.closest(".snapshot-option") : null
809
+ if (option) option.remove()
810
+ if (Array.isArray(item.snapshots)) {
811
+ item.snapshots = item.snapshots.filter((snap) => String(snap.id) !== idStr)
812
+ } else {
813
+ item.snapshots = []
814
+ }
815
+ if (installedBySnapshot && Object.prototype.hasOwnProperty.call(installedBySnapshot, idStr)) {
816
+ delete installedBySnapshot[idStr]
817
+ }
818
+ updateItemRow(item)
819
+ } else {
820
+ const msg = payload && payload.error ? payload.error : "Delete failed"
821
+ Swal.fire({ icon: "error", title: "Error", text: msg })
822
+ }
823
+ } catch (error) {
824
+ Swal.fire({ icon: "error", title: "Error", text: error && error.message ? error.message : "Delete failed" })
825
+ } finally {
826
+ if (btn) {
827
+ btn.disabled = false
828
+ btn.textContent = "Delete"
829
+ }
830
+ setLoading(false)
831
+ }
832
+ }
833
+
715
834
  installButtons.forEach((btn) => {
716
835
  btn.addEventListener("click", () => {
717
836
  const snapshotId = btn.getAttribute("data-snapshot-id") || 'latest'
@@ -719,7 +838,12 @@ document.addEventListener("DOMContentLoaded", () => {
719
838
  })
720
839
  })
721
840
 
722
- const publishButtons = Array.from(container.querySelectorAll(".snapshot-publish"))
841
+ deleteButtons.forEach((btn) => {
842
+ btn.addEventListener("click", () => {
843
+ const snapshotId = btn.getAttribute("data-snapshot-id") || ''
844
+ handleDelete(snapshotId, btn)
845
+ })
846
+ })
723
847
 
724
848
  const highlightSnapshotId = opts && opts.highlightSnapshotId != null ? String(opts.highlightSnapshotId) : null
725
849
  setTimeout(() => {
@@ -0,0 +1,9 @@
1
+ [api shell.run]
2
+
3
+ The default interactive shell is now zsh.
4
+ To update your account to use zsh, please run `chsh -s /bin/zsh`.
5
+ For more details, please visit https://support.apple.com/kb/HT208050.
6
+ <<PINOKIO_SHELL>>eval "$(conda shell.bash hook)" ; conda deactivate ; conda deactivate ; conda deactivate ; conda activate base && npm install -g opencode-ai@latest
7
+
8
+ added 12 packages in 7s
9
+ (base) <<PINOKIO_SHELL>>
@@ -0,0 +1,2 @@
1
+ 2025-12-23T20:43:16.123Z [memory] {"ts":1766522596108,"step":0,"input":{"ts":"1766522595724"},"args":{"ts":"1766522595724"},"global":{},"local":{},"port":42012}
2
+ 2025-12-23T20:43:16.123Z [memory]
@@ -0,0 +1,9 @@
1
+ [api shell.run]
2
+
3
+ The default interactive shell is now zsh.
4
+ To update your account to use zsh, please run `chsh -s /bin/zsh`.
5
+ For more details, please visit https://support.apple.com/kb/HT208050.
6
+ <<PINOKIO_SHELL>>eval "$(conda shell.bash hook)" ; conda deactivate ; conda deactivate ; conda deactivate ; conda activate base && npm install -g opencode-ai@latest
7
+
8
+ added 12 packages in 7s
9
+ (base) <<PINOKIO_SHELL>>