@notis_ai/cli 0.2.0-beta.161.1 → 0.2.0-beta.164.1

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.
@@ -2365,11 +2365,24 @@ async function syncSymlinks(skills, skillsDir = LEGACY_AGENTS_SKILLS_DIR, option
2365
2365
  }
2366
2366
  return result;
2367
2367
  }
2368
- function getPushCandidates(localSkills, syncState, cloudCuratedSkillNames = /* @__PURE__ */ new Set(), cloudSkillNames) {
2368
+ function selectCloudSkillsToApply(skills) {
2369
+ const winners = /* @__PURE__ */ new Map();
2370
+ for (const skill of skills) {
2371
+ const current = winners.get(skill.name);
2372
+ if (!current || (skill.updated_at || "") >= (current.updated_at || "")) {
2373
+ winners.set(skill.name, skill);
2374
+ }
2375
+ }
2376
+ return [...winners.values()];
2377
+ }
2378
+ function getPushCandidates(localSkills, syncState, cloudCuratedSkillNames = /* @__PURE__ */ new Set(), cloudSkillNames, cloudAppOwnedSkillNames = /* @__PURE__ */ new Set()) {
2369
2379
  return localSkills.filter((skill) => {
2370
2380
  if (cloudCuratedSkillNames.has(skill.name)) {
2371
2381
  return false;
2372
2382
  }
2383
+ if (cloudAppOwnedSkillNames.has(skill.name)) {
2384
+ return false;
2385
+ }
2373
2386
  const previous = syncState.skills[skill.name];
2374
2387
  if (!previous) {
2375
2388
  return true;
@@ -2482,17 +2495,28 @@ function shouldWriteCloudSkill(cloudSkill, localSkills, previousState) {
2482
2495
  }
2483
2496
  const previous = previousState.skills[skillName];
2484
2497
  if (previous?.folderHash === localSkill.folderHash && previous.cloudContentHash === cloudContentHash(cloudSkill)) return false;
2498
+ if (previous && previous.cloudContentHash === void 0 && previous.cloudId === cloudSkill.id && previous.appliedCloudFolderHash !== void 0 && previous.appliedCloudFolderHash === cloudHash && previous.cloudUpdatedAt === cloudSkill.updated_at && previous.folderHash === localSkill.folderHash) {
2499
+ return false;
2500
+ }
2485
2501
  if (cloudSkill.source === "curated") {
2486
2502
  return cloudHash ? cloudHash !== localSkill.folderHash : true;
2487
2503
  }
2488
2504
  const localChangedSinceLastSync = !previous || previous.folderHash !== localSkill.folderHash;
2489
2505
  return !localChangedSinceLastSync && (Boolean(cloudHash) && cloudHash !== localSkill.folderHash || Boolean(previous?.cloudContentHash && previous.cloudContentHash !== cloudContentHash(cloudSkill)));
2490
2506
  }
2491
- function buildSyncState(pullResponse, localSkills, lastSyncedAt, verifiedAgentLinks = {}, failedContentNames = /* @__PURE__ */ new Set()) {
2507
+ function collectAppliedCloudRevisions(pullResponse, previousState, writtenSkillNames) {
2508
+ const applied = {};
2509
+ for (const skill of selectCloudSkillsToApply(pullResponse.skills)) {
2510
+ applied[skill.name] = writtenSkillNames.has(skill.name) ? skill.skill_folder_hash || "" : previousState.skills[skill.name]?.appliedCloudFolderHash;
2511
+ }
2512
+ return applied;
2513
+ }
2514
+ function buildSyncState(pullResponse, localSkills, lastSyncedAt, verifiedAgentLinks = {}, failedContentNames = /* @__PURE__ */ new Set(), appliedRevisions = {}) {
2492
2515
  const localSkillMap = toSkillMap(localSkills);
2493
2516
  const skills = Object.fromEntries(
2494
- pullResponse.skills.map((skill) => {
2517
+ selectCloudSkillsToApply(pullResponse.skills).map((skill) => {
2495
2518
  const localSkill = localSkillMap.get(skill.name);
2519
+ const appliedCloudFolderHash = appliedRevisions[skill.name];
2496
2520
  return [
2497
2521
  skill.name,
2498
2522
  {
@@ -2502,6 +2526,7 @@ function buildSyncState(pullResponse, localSkills, lastSyncedAt, verifiedAgentLi
2502
2526
  verifiedAgentLinks: skill.status === "active" ? verifiedAgentLinks[skill.name] ?? {} : {},
2503
2527
  cloudUpdatedAt: skill.updated_at,
2504
2528
  ...!failedContentNames.has(skill.name) && !skill.skill_source_url ? { cloudContentHash: cloudContentHash(skill) } : {},
2529
+ ...appliedCloudFolderHash !== void 0 ? { appliedCloudFolderHash } : {},
2505
2530
  syncedAt: lastSyncedAt || (/* @__PURE__ */ new Date()).toISOString()
2506
2531
  }
2507
2532
  ];
@@ -2563,7 +2588,7 @@ async function writePulledSkillsToScopedMirror(pullResponse, localSkills, previo
2563
2588
  console.warn(`[Notis] ${message}`, error);
2564
2589
  };
2565
2590
  let downloaded = 0;
2566
- for (const cloudSkill of pullResponse.skills) {
2591
+ for (const cloudSkill of selectCloudSkillsToApply(pullResponse.skills)) {
2567
2592
  if (!shouldWriteCloudSkill(cloudSkill, localSkillMap, previousState)) {
2568
2593
  continue;
2569
2594
  }
@@ -2718,7 +2743,8 @@ async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
2718
2743
  localSkills,
2719
2744
  previousState,
2720
2745
  cloudCuratedSkillNames,
2721
- new Set(pullResponse.skills.map((skill) => skill.name))
2746
+ new Set(pullResponse.skills.map((skill) => skill.name)),
2747
+ new Set(pullResponse.skills.filter((skill) => skill.owner_app_id).map((skill) => skill.name))
2722
2748
  );
2723
2749
  const failedPushes = [];
2724
2750
  if (pushCandidates.length > 0) {
@@ -2742,13 +2768,15 @@ async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
2742
2768
  }
2743
2769
  }
2744
2770
  const failedDownloads = [];
2771
+ const writtenSkillNames = /* @__PURE__ */ new Set();
2745
2772
  const downloaded = await writePulledSkillsToScopedMirror(
2746
2773
  pullResponse,
2747
2774
  localSkills,
2748
2775
  previousState,
2749
2776
  syncPaths,
2750
2777
  deps,
2751
- failedDownloads
2778
+ failedDownloads,
2779
+ writtenSkillNames
2752
2780
  );
2753
2781
  const finalLocalSkills = (await deps.scanLocalSkills(syncPaths)).filter((skill) => !BASE_SKILL_NAMES.has(skill.name));
2754
2782
  const symlinkResult = await deps.syncSymlinks(
@@ -2759,7 +2787,14 @@ async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
2759
2787
  for (const failure of failedDownloads) delete verifiedLinks[failure.name];
2760
2788
  const lastSyncedAt = pullResponse.last_synced_at || (/* @__PURE__ */ new Date()).toISOString();
2761
2789
  await deps.writeSyncState(
2762
- buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks, new Set(failedDownloads.map((item) => item.name))),
2790
+ buildSyncState(
2791
+ pullResponse,
2792
+ finalLocalSkills,
2793
+ lastSyncedAt,
2794
+ verifiedLinks,
2795
+ new Set(failedDownloads.map((item) => item.name)),
2796
+ collectAppliedCloudRevisions(pullResponse, previousState, writtenSkillNames)
2797
+ ),
2763
2798
  syncPaths
2764
2799
  );
2765
2800
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notis_ai/cli",
3
- "version": "0.2.0-beta.161.1",
3
+ "version": "0.2.0-beta.164.1",
4
4
  "description": "Agent-first Notis CLI for apps and generic tool execution",
5
5
  "type": "module",
6
6
  "bin": {
@@ -126,12 +126,12 @@ export function resolveBuiltBundleDir(projectDir) {
126
126
 
127
127
  function normalizeShadowScopedCss(css) {
128
128
  return css
129
- // Tailwind preflight emits `html,:host` in v3. Inside a shadow tree we want
130
- // the shadow host itself to carry those defaults.
131
- .replace(/html\s*,\s*:host\s*\{/g, ':host{')
132
- .replace(/:root\s*,\s*:host\s*\{/g, ':host{')
133
- .replace(/:root\s*\{/g, ':host{')
134
- .replace(/html\s*\{/g, ':host{')
129
+ // Keep defaults on both the Portal shadow host and the app root used
130
+ // by the standalone harness. Removing :root erases Tailwind v4 theme
131
+ // variables in previews, collapsing spacing, type sizes and controls.
132
+ .replace(/(?:html|:root)\s*,\s*:host\s*\{/g, '[data-notis-app-root],:host{')
133
+ .replace(/:root\s*\{/g, '[data-notis-app-root],:host{')
134
+ .replace(/html\s*\{/g, '[data-notis-app-root],:host{')
135
135
  // Shadow trees do not contain a body element. Route those defaults to the
136
136
  // app root contract instead so authors still get the expected reset.
137
137
  .replace(/body\s*\{/g, '[data-notis-app-root]{');
@@ -407,7 +407,7 @@ function connectedCallbackHtml({ portalOrigin }) {
407
407
  <button class="button button-primary" id="download-desktop" type="button">Download Notis Desktop</button>
408
408
  <a class="button" href="${escapeHtml(webAppUrl)}">Open Web App</a>
409
409
  </div>
410
- <p class="helper">The CLI stays connected even if you continue in the web app.</p>
410
+ <p class="helper">Desktop is available for macOS and Windows. The CLI stays connected even if you continue in the web app.</p>
411
411
  </section>
412
412
  <section class="next-step" id="desktop-ready-view" aria-labelledby="desktop-ready-title" hidden>
413
413
  <div class="status"><span class="status-dot"></span> Download started</div>
@@ -433,7 +433,6 @@ function connectedCallbackHtml({ portalOrigin }) {
433
433
  const resolveDownload = async () => {
434
434
  const ua = navigator.userAgent || '';
435
435
  if (/Windows/i.test(ua)) return downloads.base + '/win32/x64/notis-x64.exe';
436
- if (/Linux/i.test(ua) && !/Android/i.test(ua)) return downloads.base + '/linux/x64/notis-linux-x64.zip';
437
436
  if (/Macintosh|Mac OS X/i.test(ua)) {
438
437
  let architecture = '';
439
438
  try {
@@ -450,6 +449,7 @@ function connectedCallbackHtml({ portalOrigin }) {
450
449
  const startDownload = async () => {
451
450
  const downloadUrl = await resolveDownload();
452
451
  window.open(downloadUrl, '_blank', 'noopener,noreferrer');
452
+ if (downloadUrl === downloads.fallback) return;
453
453
  connectedView.hidden = true;
454
454
  readyView.hidden = false;
455
455
  document.title = 'Quick login to Notis Desktop';
@@ -32,7 +32,7 @@ import {
32
32
  syncSymlinks,
33
33
  type DeletedAgentSymlink,
34
34
  } from "./symlink-manager";
35
- import { getPushCandidates } from "./sync-plan";
35
+ import { getPushCandidates, selectCloudSkillsToApply } from "./sync-plan";
36
36
  import { writeCloudSkillWithBundleFallback } from "./write-cloud-skill";
37
37
  export { fetchSyncSettings } from './cloud-client';
38
38
 
@@ -179,6 +179,24 @@ export function shouldWriteCloudSkill(
179
179
  if (previous?.folderHash === localSkill.folderHash
180
180
  && previous.cloudContentHash === cloudContentHash(cloudSkill)) return false;
181
181
 
182
+ // Without a stored content baseline (skills served through a signed source URL keep
183
+ // none, because that URL changes on every pull) the only other signal is
184
+ // `skill_folder_hash`, which the server does NOT always compute the way the local
185
+ // folder hash is computed. An app-published skill hashes its bundle entries, so that
186
+ // value can never equal the hash of the extracted directory, and comparing the two
187
+ // replaced the folder on every sync tick, deleting whatever a running skill had
188
+ // written inside it. Compare cloud against cloud instead: rewrite when the cloud
189
+ // revision we last applied has actually moved.
190
+ if (previous
191
+ && previous.cloudContentHash === undefined
192
+ && previous.cloudId === cloudSkill.id
193
+ && previous.appliedCloudFolderHash !== undefined
194
+ && previous.appliedCloudFolderHash === cloudHash
195
+ && previous.cloudUpdatedAt === cloudSkill.updated_at
196
+ && previous.folderHash === localSkill.folderHash) {
197
+ return false;
198
+ }
199
+
182
200
  if (cloudSkill.source === "curated") {
183
201
  return cloudHash ? cloudHash !== localSkill.folderHash : true;
184
202
  }
@@ -192,17 +210,42 @@ export function shouldWriteCloudSkill(
192
210
  );
193
211
  }
194
212
 
213
+ type AppliedCloudRevisions = Record<string, string | undefined>;
214
+
215
+ /**
216
+ * The cloud revision each folder's last successful write applied: this run's revision
217
+ * for the skills we just wrote, the previously recorded one otherwise. A failed write
218
+ * records nothing, so the next sync retries it.
219
+ */
220
+ function collectAppliedCloudRevisions(
221
+ pullResponse: SyncPullResponse,
222
+ previousState: NotisSyncState,
223
+ writtenSkillNames: ReadonlySet<string>,
224
+ ): AppliedCloudRevisions {
225
+ const applied: AppliedCloudRevisions = {};
226
+ for (const skill of selectCloudSkillsToApply(pullResponse.skills)) {
227
+ applied[skill.name] = writtenSkillNames.has(skill.name)
228
+ ? skill.skill_folder_hash || ""
229
+ : previousState.skills[skill.name]?.appliedCloudFolderHash;
230
+ }
231
+ return applied;
232
+ }
233
+
195
234
  function buildSyncState(
196
235
  pullResponse: SyncPullResponse,
197
236
  localSkills: LocalSkill[],
198
237
  lastSyncedAt: string | null,
199
238
  verifiedAgentLinks: Record<string, Partial<AgentTargets>> = {},
200
239
  failedContentNames: ReadonlySet<string> = new Set(),
240
+ appliedRevisions: AppliedCloudRevisions = {},
201
241
  ): NotisSyncState {
202
242
  const localSkillMap = toSkillMap(localSkills);
243
+ // The same row selection the writer used, so the state describes the revision that
244
+ // is actually on disk rather than whichever duplicate row the server listed last.
203
245
  const skills = Object.fromEntries(
204
- pullResponse.skills.map((skill) => {
246
+ selectCloudSkillsToApply(pullResponse.skills).map((skill) => {
205
247
  const localSkill = localSkillMap.get(skill.name);
248
+ const appliedCloudFolderHash = appliedRevisions[skill.name];
206
249
  return [
207
250
  skill.name,
208
251
  {
@@ -213,6 +256,7 @@ function buildSyncState(
213
256
  cloudUpdatedAt: skill.updated_at,
214
257
  ...(!failedContentNames.has(skill.name) && !skill.skill_source_url
215
258
  ? { cloudContentHash: cloudContentHash(skill) } : {}),
259
+ ...(appliedCloudFolderHash !== undefined ? { appliedCloudFolderHash } : {}),
216
260
  syncedAt: lastSyncedAt || new Date().toISOString(),
217
261
  },
218
262
  ];
@@ -305,7 +349,7 @@ async function writePulledSkillsToScopedMirror(
305
349
  };
306
350
 
307
351
  let downloaded = 0;
308
- for (const cloudSkill of pullResponse.skills) {
352
+ for (const cloudSkill of selectCloudSkillsToApply(pullResponse.skills)) {
309
353
  if (!shouldWriteCloudSkill(cloudSkill, localSkillMap, previousState)) {
310
354
  continue;
311
355
  }
@@ -420,6 +464,7 @@ export async function materializeCloudSkillsForLocalShell(
420
464
  const materializedState = buildSyncState(
421
465
  pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks,
422
466
  new Set(failedDownloads.map(item => item.name)),
467
+ collectAppliedCloudRevisions(pullResponse, previousState, writtenSkillNames),
423
468
  );
424
469
  // Pull-only refresh is not an upload acknowledgement. Keep content baselines
425
470
  // unless we actually wrote cloud content, and retain cloud-missing entries so
@@ -626,6 +671,7 @@ export async function runSkillSync(
626
671
  previousState,
627
672
  cloudCuratedSkillNames,
628
673
  new Set(pullResponse.skills.map((skill) => skill.name)),
674
+ new Set(pullResponse.skills.filter((skill) => skill.owner_app_id).map((skill) => skill.name)),
629
675
  );
630
676
 
631
677
  const failedPushes: SkillSyncFailure[] = [];
@@ -654,6 +700,7 @@ export async function runSkillSync(
654
700
  }
655
701
 
656
702
  const failedDownloads: SkillSyncFailure[] = [];
703
+ const writtenSkillNames = new Set<string>();
657
704
  const downloaded = await writePulledSkillsToScopedMirror(
658
705
  pullResponse,
659
706
  localSkills,
@@ -661,6 +708,7 @@ export async function runSkillSync(
661
708
  syncPaths,
662
709
  deps,
663
710
  failedDownloads,
711
+ writtenSkillNames,
664
712
  );
665
713
 
666
714
  const finalLocalSkills = (await deps.scanLocalSkills(syncPaths))
@@ -674,7 +722,14 @@ export async function runSkillSync(
674
722
  const lastSyncedAt = pullResponse.last_synced_at || new Date().toISOString();
675
723
 
676
724
  await deps.writeSyncState(
677
- buildSyncState(pullResponse, finalLocalSkills, lastSyncedAt, verifiedLinks, new Set(failedDownloads.map(item => item.name))),
725
+ buildSyncState(
726
+ pullResponse,
727
+ finalLocalSkills,
728
+ lastSyncedAt,
729
+ verifiedLinks,
730
+ new Set(failedDownloads.map(item => item.name)),
731
+ collectAppliedCloudRevisions(pullResponse, previousState, writtenSkillNames),
732
+ ),
678
733
  syncPaths,
679
734
  );
680
735
 
@@ -1,15 +1,40 @@
1
- import type { LocalSkill, NotisSyncState } from "./types";
1
+ import type { CloudSkill, LocalSkill, NotisSyncState } from "./types";
2
+
3
+ /**
4
+ * One cloud row per local folder. Several active rows can carry the same skill name
5
+ * (an app's source skill plus its installed clone, or duplicates created by earlier
6
+ * pushes), and every one of them writes to the same directory. Applying them all in
7
+ * one pass replaced that directory several times per sync and left whichever row came
8
+ * last on disk. The newest revision wins, deterministically.
9
+ */
10
+ export function selectCloudSkillsToApply(skills: CloudSkill[]): CloudSkill[] {
11
+ const winners = new Map<string, CloudSkill>();
12
+ for (const skill of skills) {
13
+ const current = winners.get(skill.name);
14
+ if (!current || (skill.updated_at || "") >= (current.updated_at || "")) {
15
+ winners.set(skill.name, skill);
16
+ }
17
+ }
18
+ return [...winners.values()];
19
+ }
2
20
 
3
21
  export function getPushCandidates(
4
22
  localSkills: LocalSkill[],
5
23
  syncState: NotisSyncState,
6
24
  cloudCuratedSkillNames: ReadonlySet<string> = new Set(),
7
25
  cloudSkillNames?: ReadonlySet<string>,
26
+ cloudAppOwnedSkillNames: ReadonlySet<string> = new Set(),
8
27
  ): LocalSkill[] {
9
28
  return localSkills.filter((skill) => {
10
29
  if (cloudCuratedSkillNames.has(skill.name)) {
11
30
  return false;
12
31
  }
32
+ // App-owned content belongs to the installed app's release, not to this account.
33
+ // Pushing it created a second row with the same name on every local difference,
34
+ // and each new row then fought over the same folder.
35
+ if (cloudAppOwnedSkillNames.has(skill.name)) {
36
+ return false;
37
+ }
13
38
  const previous = syncState.skills[skill.name];
14
39
  if (!previous) {
15
40
  return true;
@@ -14,6 +14,11 @@ export interface SyncedSkill {
14
14
  cloudUpdatedAt?: string;
15
15
  /** Content accepted on disk; independent of server-specific folder hash formats. */
16
16
  cloudContentHash?: string;
17
+ /** `skill_folder_hash` of the cloud revision a successful write actually applied.
18
+ * The server may compute that hash with a different construction than the local
19
+ * folder hash (app-published skills do), so only cloud-to-cloud comparison tells
20
+ * "the cloud moved" apart from "the two sides hash differently". */
21
+ appliedCloudFolderHash?: string;
17
22
  syncedAt: string;
18
23
  }
19
24
 
@@ -49,6 +54,9 @@ export interface CloudSkill {
49
54
  skill_source_url?: string | null;
50
55
  bundle_files?: BundleFile[] | null;
51
56
  bundle_hydration_failed?: boolean | null;
57
+ /** Set when the skill's content is owned by an installed Notis app. Its folder is
58
+ * republished by the app, never by a local push. */
59
+ owner_app_id?: string | null;
52
60
  source: string;
53
61
  status: string;
54
62
  }
@@ -99,7 +99,7 @@
99
99
  </head>
100
100
  <body>
101
101
  <div id="harness-status">harness: booting</div>
102
- <div id="root"></div>
102
+ <div id="root" data-notis-app-root></div>
103
103
  <script type="module">
104
104
  const routeExport = {{ROUTE_EXPORT}};
105
105
  const descriptor = {{RUNTIME_DESCRIPTOR}};