@notis_ai/cli 0.2.0-beta.196.1 → 0.2.0-beta.198.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.
Files changed (33) hide show
  1. package/README.md +11 -11
  2. package/config/portal-route-roots.json +0 -1
  3. package/dist/agent-hooks/notis-agent-hook.mjs +760 -473
  4. package/dist/base-skills/notis-apps/SKILL.md +4 -3
  5. package/dist/base-skills/notis-apps/references/sdk.md +3 -1
  6. package/dist/base-skills/notis-apps/references/views.md +1 -1
  7. package/dist/base-skills/notis-cli/SKILL.md +7 -5
  8. package/dist/base-skills/notis-query/references/documents.md +4 -3
  9. package/dist/sdk/src/components/DocumentPage.tsx +6 -3
  10. package/dist/sdk/src/runtime.ts +10 -2
  11. package/dist/skill-sync/index.js +78 -12
  12. package/dist/skill-sync/index.js.map +3 -3
  13. package/dist/skill-sync-worker.mjs +391 -175
  14. package/dist/view-renderer/dist/host.js +86 -86
  15. package/dist/view-renderer/src/host.tsx +4 -2
  16. package/package.json +1 -1
  17. package/skills/notis-apps/cli.md +1 -1
  18. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +1 -1
  19. package/src/command-specs/auth.js +2 -2
  20. package/src/command-specs/handover.js +45 -16
  21. package/src/command-specs/profile.js +42 -1
  22. package/src/command-specs/skills.js +13 -1
  23. package/src/runtime/computer-identity.d.ts +30 -0
  24. package/src/runtime/computer-identity.js +151 -0
  25. package/src/runtime/config-lock.js +135 -0
  26. package/src/runtime/oauth.js +18 -1
  27. package/src/runtime/profiles.js +5 -107
  28. package/src/runtime/skill-sync/index.ts +83 -7
  29. package/src/runtime/skill-sync/local-scanner.ts +7 -4
  30. package/src/runtime/skill-sync/native-plan.ts +14 -3
  31. package/src/runtime/sync-skills.d.ts +3 -1
  32. package/src/runtime/sync-skills.js +10 -2
  33. package/src/skill-sync-worker.js +14 -2
@@ -84,9 +84,10 @@ from deploying an installed Space and requires the user's explicit authority.
84
84
  Existing report rows render through `<ReportFrame>` in their database's view.
85
85
  Use `notis-reports` for an independently authored report when appropriate.
86
86
 
87
- For a saved HTML page, use the installed **HTML Space** and its linked **Save HTML**
88
- Skill. That Skill owns the row-first upload-and-attach workflow. If the Space is
89
- not installed, suggest installing it; creating an unrelated host is not a substitute.
87
+ Saved HTML pages and reports are notes in **Notis Notes**: save them with the
88
+ **Save HTML** Skill that comes with Notes, which owns the row-first
89
+ upload-and-attach workflow (see `notis-reports`). If Notes is not installed,
90
+ suggest installing it; creating an unrelated host is not a substitute.
90
91
 
91
92
  [Context sharing](references/context.md) keeps selected text and annotations as
92
93
  unsent reference context. Page context, memory hits and source content are not
@@ -34,7 +34,7 @@ for loading/error/empty states and explicit read caching.
34
34
 
35
35
  | Component | Contract |
36
36
  | --- | --- |
37
- | `<DocumentPage recordKey>` | The full page of a record that is the view's main content (a note, a document): breadcrumb, Saving or Saved, '...' menu, cover, icon, title, meta strip, properties that save automatically, collaborative body. Slots `breadcrumb`, `actions`, `menuItems`, `aboveBody`, `belowBody`; toggles `share` (off by default), `header`, `meta`, `properties`, `width`; `onSaved`; `layout` to rearrange its parts. Pair with `DocumentPageSkeleton` and `usePrefetchRecord()`. Until the Notis CLI you build with exports it (`spaces build` replaces the Space's `packages/sdk` with the CLI's own SDK, and older ones have no `DocumentPage`, so the import fails the build), reach the same host page through `useNotisRuntime().ui?.DocumentPage` and fall back to `DocumentEditor` when it is missing, as the Notes Spaces do in `lib/document-page.tsx`. |
37
+ | `<DocumentPage recordKey>` | The full page of a record that is the view's main content (a note, a document): breadcrumb, Saving or Saved, '...' menu, cover, icon, title, meta strip, properties that save automatically, collaborative body. Slots `breadcrumb`, `actions`, `menuItems`, `aboveBody`, `belowBody`; toggles `share` (off by default, never offered for a report record), `header`, `meta`, `properties`, `width`; `viewer: 'html'` opens a record whose one stored `.html` file is the page (such as an HTML page saved in Notis Notes) full height under the toolbar, without header, meta, properties or body editor; `onSaved`; `layout` to rearrange its parts. Pair with `DocumentPageSkeleton` and `usePrefetchRecord()`. Until the Notis CLI you build with exports it (`spaces build` replaces the Space's `packages/sdk` with the CLI's own SDK, and older ones have no `DocumentPage`, so the import fails the build), reach the same host page through `useNotisRuntime().ui?.DocumentPage` and fall back to `DocumentEditor` when it is missing, as the Notes Spaces do in `lib/document-page.tsx`. |
38
38
  | `<DocumentEditor recordKey>` | Native collaborative body, properties, images and attachments. With separate properties use `showProperties={false}`. |
39
39
  | `<RecordProperties recordKey>` | Native revision-checked property editing. |
40
40
  | `<HtmlFrame recordKey>` | Attached HTML under viewer CSP and an opaque-origin sandbox. Source cannot relax that boundary. |
@@ -44,6 +44,8 @@ for loading/error/empty states and explicit read caching.
44
44
  The published Space declares the database binding; the server rechecks record
45
45
  scope and binding revision. A Site exposes read-only body/property snapshots and
46
46
  HTML for its pinned record, including a declared non-collection record param.
47
+ A report row (`<ReportFrame>`) cannot be shared as a Site; a report saved as an
48
+ HTML file can.
47
49
  It has no upload, collaboration credential, authoring controls or sharing mutation.
48
50
  Preview source does not gain live record editing. No app-owned credentials or
49
51
  filesystem URLs belong in component props.
@@ -127,7 +127,7 @@ because a pending main view cannot yet open one.
127
127
  ## Rendering and memory
128
128
 
129
129
  Choose [memory](memory.md) deliberately. `chrome: 'hidden'` is for full-screen
130
- presentations such as the HTML Space; ordinary collection views keep Portal chrome.
130
+ presentations; ordinary collection views keep Portal chrome.
131
131
  A Space whose published view hides chrome starts hidden from each person's sidebar,
132
132
  like Skill review Spaces; it stays reachable by link and search, and each person
133
133
  can show it from the sidebar's hidden Spaces list or the Space's menu.
@@ -38,11 +38,13 @@ The CLI bundles this `notis-cli` base skill and refreshes its canonical copy und
38
38
 
39
39
  A profile is one account paired with one API endpoint. Profiles live side by side; switching between them never signs any of them out.
40
40
 
41
- - `npx --package @notis_ai/cli@latest -- notis profile list` — every profile on this machine, with its endpoint, user, and whether it is signed in. The active one is marked.
42
- - `npx --package @notis_ai/cli@latest -- notis profile use <name>` — change which account subsequent commands run as.
43
- - `npx --package @notis_ai/cli@latest -- notis --profile <name> <command>` — run a single command as another account without changing the active one.
44
- - `npx --package @notis_ai/cli@latest -- notis login --profile <name>` — add an account. The existing profiles keep their credentials.
45
- - `npx --package @notis_ai/cli@latest -- notis logout` — sign out of the active profile only; pass `--all-profiles` to clear every one.
41
+ Notis Desktop signs the CLI in for every account saved in the app, one profile each. `default` keeps its name; each added account gets a profile named after its email with `@` turned into `-` (for example `flo-mindtheflo.com`), and Beta Desktop adds `-beta`. Switching accounts in Desktop never changes the active profile. Only the active profile's skills are linked into Claude Code, Codex and Cursor; every other account's skills sync into their own folder. Signing an account out of Desktop signs it out of the CLI on this computer and removes the profile and skills Desktop made for it. If that was the active profile, the CLI switches to the account Desktop opens next; with no other signed-in Desktop account it stays signed out. Profiles made with `notis login` are never removed or picked automatically.
42
+
43
+ - `npx --package @notis_ai/cli@latest -- notis profile list`: every profile on this machine, with its endpoint, user, and whether it is signed in. The active one is marked.
44
+ - `npx --package @notis_ai/cli@latest -- notis profile use <name>`: change which account subsequent commands run as, and link that account's skills into the agent folders straight away.
45
+ - `npx --package @notis_ai/cli@latest -- notis --profile <name> <command>`: run a single command as another account without changing the active one.
46
+ - `npx --package @notis_ai/cli@latest -- notis login --profile <name>`: add an account. The existing profiles keep their credentials.
47
+ - `npx --package @notis_ai/cli@latest -- notis logout`: sign this computer out of the active profile only; the account stays signed in on other computers. Pass `--all-profiles` to clear every profile on this computer.
46
48
 
47
49
  Read the profile before acting on the user's data. `notis whoami` reports the account and endpoint a command will hit; if that is not the account the user meant, switch profiles rather than proceeding.
48
50
 
@@ -48,9 +48,10 @@ uncertain upload retries only the identical byte/intent request; it never replac
48
48
  an existing shared retry object. Preserve previous files unless the user asked
49
49
  for their removal.
50
50
 
51
- For HTML pages, use the installed HTML Space's linked Save HTML Skill, which owns
52
- its complete save/update/sharing workflow. Suggest installing that Space when
53
- absent. Deliver its record view link, not the raw file storage location.
51
+ For HTML pages and reports, use the Save HTML Skill that comes with Notis Notes,
52
+ which owns their complete save/update/sharing workflow (see `notis-reports`).
53
+ Suggest installing Notis Notes when absent. Deliver the note's Notes link, not the
54
+ raw file storage location.
54
55
 
55
56
  ## Fresh memory evidence
56
57
 
@@ -3,7 +3,7 @@
3
3
  import { useCallback, useRef, type CSSProperties, type ReactElement } from 'react';
4
4
  import { useNotisRuntime } from '../provider';
5
5
  import { DocumentEditor } from './DocumentEditor';
6
- import { ShareControl } from './RecordComponents';
6
+ import { HtmlFrame, ShareControl } from './RecordComponents';
7
7
  import { Skeleton } from './Skeleton';
8
8
  import type { NotisDocumentPageProps } from '../runtime';
9
9
 
@@ -69,6 +69,7 @@ export function DocumentPageSkeleton({ width = 'standard', className, label }: {
69
69
  * Shown by hosts that predate DocumentPage: the same record, with the editor these hosts already have.
70
70
  * menuItems show as plain toolbar buttons and onSaved is called when a save finishes (title null);
71
71
  * layout, header parts, meta and width options other than the column are not available there.
72
+ * With viewer 'html' the host's HtmlFrame replaces the editor.
72
73
  */
73
74
  function DocumentPageFallback(props: NotisDocumentPageProps) {
74
75
  const t = useCopy();
@@ -99,12 +100,13 @@ function DocumentPageFallback(props: NotisDocumentPageProps) {
99
100
  style={{ background: 'none', border: 0, padding: '4px 8px', borderRadius: 6, cursor: item.disabled ? 'default' : 'pointer', font: 'inherit', fontSize: 13,
100
101
  color: item.destructive ? 'hsl(var(--destructive))' : 'hsl(var(--muted-foreground))', opacity: item.disabled ? 0.5 : 1 }}>{item.label}</button>)}
101
102
  </div> : null}
103
+ {props.viewer === 'html' ? <HtmlFrame recordKey={recordKey} /> :
102
104
  <div style={{ ...columnStyle(width), padding: '24px 16px 96px', display: 'flex', flexDirection: 'column', gap: 20 }}>
103
105
  {aboveBody}
104
106
  <DocumentEditor recordKey={recordKey} variant={props.header === false ? 'body' : 'full'} showProperties={props.properties !== 'hidden'}
105
107
  readOnly={readOnly} onDirtyChange={props.onDirtyChange} onSavingChange={savingChange} />
106
108
  {belowBody}
107
- </div>
109
+ </div>}
108
110
  </div>;
109
111
  }
110
112
 
@@ -114,7 +116,8 @@ function DocumentPageFallback(props: NotisDocumentPageProps) {
114
116
  * automatically, and the collaborative body with presence. The host (Portal) draws it and keeps one
115
117
  * save queue for the whole record. Files open in their viewer under the header; HTML and reports show
116
118
  * only the toolbar (breadcrumb, actions, Share, '...' menu) above their frame, with no header, meta,
117
- * properties, aboveBody or belowBody.
119
+ * properties, aboveBody or belowBody. `viewer: 'html'` opens a record whose one stored `.html` file is
120
+ * the page (for example a note that keeps a saved HTML report) the same way.
118
121
  *
119
122
  * ```tsx
120
123
  * if (params.note) return <DocumentPage recordKey={params.note}
@@ -331,6 +331,11 @@ export interface NotisDocumentPageProps extends NotisRecordProps {
331
331
  belowBody?: ReactNode;
332
332
  /** Column width: standard (900px, as Beta), wide (1200px) or full. */
333
333
  width?: 'standard' | 'wide' | 'full';
334
+ /**
335
+ * 'html': the record's one stored `.html` file is the page. It opens full height under the toolbar,
336
+ * like an HTML record, without the header, meta, properties or body editor.
337
+ */
338
+ viewer?: 'html';
334
339
  readOnly?: boolean;
335
340
  onDirtyChange?: (dirty: boolean) => void;
336
341
  onSavingChange?: (saving: boolean) => void;
@@ -442,8 +447,8 @@ export interface HandoverResult {
442
447
  * The user's cloud computer, as far as an app may see it.
443
448
  *
444
449
  * `exists` is false when the user has never had a sandbox provisioned. A
445
- * `status` of anything other than `'running'` means the VM is asleep; reading
446
- * these facts never wakes it.
450
+ * `status` is the last observed VM state; `freshness` and `observed_at` describe
451
+ * its age. Reading these facts never wakes the VM.
447
452
  */
448
453
  export interface CloudComputerSandboxFacts {
449
454
  exists: boolean;
@@ -451,6 +456,8 @@ export interface CloudComputerSandboxFacts {
451
456
  provider: string | null;
452
457
  created_at: string | null;
453
458
  updated_at: string | null;
459
+ observed_at?: string | null;
460
+ freshness?: 'fresh' | 'stale' | 'unknown';
454
461
  }
455
462
 
456
463
  /**
@@ -465,6 +472,7 @@ export interface CloudComputerCliAuthFacts {
465
472
  authenticated: boolean | null;
466
473
  account: string | null;
467
474
  checked_at: string | null;
475
+ freshness?: 'fresh' | 'stale' | 'unknown';
468
476
  reason: string | null;
469
477
  }
470
478
 
@@ -1,6 +1,7 @@
1
1
  // src/runtime/skill-sync/index.ts
2
2
  import { createHash as createHash4 } from "node:crypto";
3
- import { join } from "node:path";
3
+ import { promises as fs3 } from "node:fs";
4
+ import { dirname, join } from "node:path";
4
5
 
5
6
  // src/runtime/skill-sync/types.ts
6
7
  var DEFAULT_AGENT_TARGETS = {
@@ -113,8 +114,16 @@ function previousNativeState(state, skill) {
113
114
  function nativeLocalReady(skill, local) {
114
115
  return Boolean(local?.nativeFilesCurrent && local.folderHash === local.nativeBaseFolderHash && local.cloudId === skill.id && local.nativeScope === skill.native_scope && sameNativeReference(local.nativeReference, skill.native_reference));
115
116
  }
116
- function nativeLinkRows(response, locals) {
117
- return response.skills.map((skill) => isNativeSkill(skill) && !nativeLocalReady(skill, locals.find((row) => row.name === skill.name)) ? { ...skill, status: "disabled" } : skill);
117
+ function pendingLegacyUpgrade(skill, local, state) {
118
+ if (!isNativeSkill(skill) || !local || local.nativeReference || local.cloudId) return false;
119
+ const previous = previousNativeState(state, skill);
120
+ return Boolean(previous && !previous.nativeReference);
121
+ }
122
+ function nativeLinkRows(response, locals, state) {
123
+ return response.skills.map((skill) => {
124
+ const local = locals.find((row) => row.name === skill.name);
125
+ return isNativeSkill(skill) && !nativeLocalReady(skill, local) && !(state && pendingLegacyUpgrade(skill, local, state)) ? { ...skill, status: "disabled" } : skill;
126
+ });
118
127
  }
119
128
  function retainMissingNativeState(previous, next) {
120
129
  const present = new Set(Object.values(next.skills).map((row) => row.cloudId));
@@ -896,7 +905,12 @@ async function replaceSkillDirectoryAtomically(skillDir, populateDir, preserve)
896
905
  }
897
906
  await populateDir(stagingDir);
898
907
  if (preserve && await pathExists(skillDir)) {
899
- await preserveUnmanagedFiles(skillDir, stagingDir, preserve.previouslyApplied, preserve.native);
908
+ await preserveUnmanagedFiles(
909
+ skillDir,
910
+ stagingDir,
911
+ preserve.previouslyApplied,
912
+ preserve.native && !preserve.legacyBaseline
913
+ );
900
914
  }
901
915
  if (preserve?.native) {
902
916
  const markerPath = path.join(stagingDir, NATIVE_IDENTITY_FILE);
@@ -993,7 +1007,9 @@ async function writeCloudSkillToDisk(skill, bundleBytes, paths = DEFAULT_SYNC_PA
993
1007
  const preserve = {
994
1008
  previouslyApplied: options.previouslyApplied,
995
1009
  expectedFolderHash: options.expectedFolderHash,
996
- native: isNativeSkill(skill)
1010
+ native: isNativeSkill(skill),
1011
+ // An unchanged legacy folder upgrading in place: shared files take the cloud version.
1012
+ legacyBaseline: isNativeSkill(skill) && options.legacyBaseline === true && options.expectedFolderHash !== void 0
997
1013
  };
998
1014
  if (bundleBytes?.length) {
999
1015
  const bundlePath = path.join(
@@ -1560,6 +1576,44 @@ var DEFAULT_RUN_SKILL_SYNC_DEPS = {
1560
1576
  removeForeignAccountSymlinks,
1561
1577
  updateAgentTargets
1562
1578
  };
1579
+ var MIRROR_ONLY_DEPS = {
1580
+ removeForeignAccountSymlinks: async () => 0,
1581
+ removeAllSymlinksForSkill: async () => 0,
1582
+ syncSymlinks: async () => ({ linked: 0, removed: 0, skipped: 0, verifiedAgentLinks: {} })
1583
+ };
1584
+ async function forgetOtherAccountsAgentLinks(paths) {
1585
+ const usersDir = dirname(dirname(paths.syncStatePath));
1586
+ let entries;
1587
+ try {
1588
+ entries = await fs3.readdir(usersDir, { withFileTypes: true });
1589
+ } catch {
1590
+ return;
1591
+ }
1592
+ for (const entry of entries) {
1593
+ if (!entry.isDirectory()) continue;
1594
+ const statePath = join(usersDir, entry.name, ".notis-sync.json");
1595
+ if (statePath === paths.syncStatePath) continue;
1596
+ let state = null;
1597
+ try {
1598
+ state = JSON.parse(await fs3.readFile(statePath, "utf8"));
1599
+ } catch {
1600
+ continue;
1601
+ }
1602
+ if (!state || typeof state !== "object" || !state.skills || typeof state.skills !== "object") continue;
1603
+ let changed = false;
1604
+ for (const skill of Object.values(state.skills)) {
1605
+ if (skill && typeof skill === "object" && "verifiedAgentLinks" in skill) {
1606
+ delete skill.verifiedAgentLinks;
1607
+ changed = true;
1608
+ }
1609
+ }
1610
+ if (!changed) continue;
1611
+ const temporaryPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
1612
+ await fs3.writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}
1613
+ `, "utf8");
1614
+ await fs3.rename(temporaryPath, statePath);
1615
+ }
1616
+ }
1563
1617
  function isolatedLinkOptions(paths) {
1564
1618
  if (!paths.isolatedNamespace && !paths.homeDir) return void 0;
1565
1619
  const root = paths.isolatedNamespace ? join(paths.syncRoot, "environments", paths.isolatedNamespace, "agents") : paths.homeDir;
@@ -1647,6 +1701,10 @@ function buildSyncState(pullResponse, localSkills, lastSyncedAt, verifiedAgentLi
1647
1701
  const localSkill = localSkillMap.get(skill.name);
1648
1702
  const appliedCloudFolderHash = appliedRevisions[skill.name];
1649
1703
  const appliedFileList = appliedFiles[skill.name];
1704
+ if (pendingLegacyUpgrade(skill, localSkill, previousState)) {
1705
+ const { nativeUnavailable: _unavailable, ...previous } = previousNativeState(previousState, skill);
1706
+ return [skill.name, { ...previous, verifiedAgentLinks: verifiedAgentLinks[skill.name] ?? {} }];
1707
+ }
1650
1708
  if (isNativeSkill(skill) && !nativeLocalReady(skill, localSkill)) {
1651
1709
  const previous = previousNativeState(previousState, skill);
1652
1710
  return [skill.name, { ...previous || {
@@ -1710,7 +1768,7 @@ function buildLocalSymlinkCandidates(pullResponse, localSkills, previousState) {
1710
1768
  status: "active"
1711
1769
  };
1712
1770
  });
1713
- return [...nativeLinkRows(pullResponse, localSkills), ...localOnlySkills];
1771
+ return [...nativeLinkRows(pullResponse, localSkills, previousState), ...localOnlySkills];
1714
1772
  }
1715
1773
  function isEmptySyncState(state) {
1716
1774
  return state.lastSyncedAt === null && Object.keys(state.skills).length === 0;
@@ -1746,6 +1804,7 @@ async function writePulledSkillsToScopedMirror(pullResponse, localSkills, previo
1746
1804
  for (const cloudSkill of selectCloudSkillsToApply(pullResponse.skills)) {
1747
1805
  const local = localSkillMap.get(cloudSkill.name);
1748
1806
  const acknowledged = isNativeSkill(cloudSkill) && local && acknowledgedNative.get(cloudSkill.id) === local.folderHash;
1807
+ const legacyBaseline = Boolean(local && pendingLegacyUpgrade(cloudSkill, local, previousState) && previousNativeState(previousState, cloudSkill)?.folderHash === local.folderHash);
1749
1808
  if (!acknowledged && !shouldWriteCloudSkill(cloudSkill, localSkillMap, previousState)) {
1750
1809
  if (isNativeSkill(cloudSkill) && !nativeLocalReady(cloudSkill, local)) {
1751
1810
  failures.push({ name: cloudSkill.name, error: "Local native Skill changes were retained; resolve the version or identity conflict before using them." });
@@ -1758,7 +1817,8 @@ async function writePulledSkillsToScopedMirror(pullResponse, localSkills, previo
1758
1817
  // What the last write put there. Everything else in the folder was
1759
1818
  // produced locally and survives this one.
1760
1819
  previouslyApplied: previousNativeState(previousState, skill)?.appliedFiles,
1761
- ...isNativeSkill(skill) && local ? { expectedFolderHash: local.folderHash } : {}
1820
+ ...isNativeSkill(skill) && local ? { expectedFolderHash: local.folderHash } : {},
1821
+ ...legacyBaseline ? { legacyBaseline } : {}
1762
1822
  }),
1763
1823
  onWarning: warnSkillSync
1764
1824
  });
@@ -1827,7 +1887,7 @@ async function materializeCloudSkillsForLocalShell(serverUrl, jwt, dependencies
1827
1887
  }
1828
1888
  if (relinkSkillNames.size > 0) {
1829
1889
  const relinked = await deps.syncSymlinks(
1830
- nativeLinkRows(pullResponse, finalLocalSkills).filter((skill) => relinkSkillNames.has(skill.name)),
1890
+ nativeLinkRows(pullResponse, finalLocalSkills, previousState).filter((skill) => relinkSkillNames.has(skill.name)),
1831
1891
  syncPaths.skillsDir,
1832
1892
  { ...isolatedLinkOptions(syncPaths), removeUndesired: false }
1833
1893
  );
@@ -1864,7 +1924,8 @@ async function materializeCloudSkillsForLocalShell(serverUrl, jwt, dependencies
1864
1924
  for (const [name, previous] of Object.entries(previousState.skills)) {
1865
1925
  if (!previous.nativeReference && !previous.nativeUnavailable) continue;
1866
1926
  const current = pullResponse.skills.find((skill) => skill.id === previous.cloudId);
1867
- if (!current || !nativeLocalReady(current, finalLocalSkills.find((local) => local.name === current.name))) {
1927
+ const local = current && finalLocalSkills.find((row) => row.name === current.name);
1928
+ if (!current || !nativeLocalReady(current, local) && !pendingLegacyUpgrade(current, local, previousState)) {
1868
1929
  removed += await deps.removeAllSymlinksForSkill(name, syncPaths.skillsDir, isolatedLinkOptions(syncPaths));
1869
1930
  }
1870
1931
  }
@@ -1950,9 +2011,11 @@ async function deactivateDeletedAgentSkills(serverUrl, jwt, pullResponse, previo
1950
2011
  return deactivated;
1951
2012
  }
1952
2013
  async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
2014
+ const linkAgentFolders = options.linkAgentFolders !== false;
1953
2015
  const deps = {
1954
2016
  ...DEFAULT_RUN_SKILL_SYNC_DEPS,
1955
- ...dependencies
2017
+ ...dependencies,
2018
+ ...linkAgentFolders ? {} : MIRROR_ONLY_DEPS
1956
2019
  };
1957
2020
  const syncSettings = await deps.fetchSyncSettings(
1958
2021
  serverUrl,
@@ -1991,13 +2054,16 @@ async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
1991
2054
  syncPaths.skillsDir,
1992
2055
  isolatedLinkOptions(syncPaths)
1993
2056
  );
2057
+ if (linkAgentFolders && !syncPaths.isolatedNamespace) {
2058
+ await forgetOtherAccountsAgentLinks(syncPaths);
2059
+ }
1994
2060
  const cloudCuratedSkillNames = new Set(
1995
2061
  pullResponse.skills.filter((skill) => skill.source === "curated").map((skill) => skill.name)
1996
2062
  );
1997
2063
  const protectedSkillNames = /* @__PURE__ */ new Set([...cloudCuratedSkillNames, ...BASE_SKILL_NAMES]);
1998
2064
  const scopedState = withoutBaseSkillState(await deps.readSyncState(syncPaths));
1999
2065
  const assignmentFailures = [];
2000
- const deactivated = !syncPaths.isolatedNamespace && syncSettings.agent_targets_conditional_updates === true ? await deactivateDeletedAgentSkills(
2066
+ const deactivated = linkAgentFolders && !syncPaths.isolatedNamespace && syncSettings.agent_targets_conditional_updates === true ? await deactivateDeletedAgentSkills(
2001
2067
  serverUrl,
2002
2068
  jwt,
2003
2069
  pullResponse,
@@ -2019,7 +2085,7 @@ async function runSkillSync(serverUrl, jwt, dependencies = {}, options = {}) {
2019
2085
  protectedSkillNames
2020
2086
  });
2021
2087
  }
2022
- if (!syncPaths.isolatedNamespace) {
2088
+ if (linkAgentFolders && !syncPaths.isolatedNamespace) {
2023
2089
  await deps.gatherTopLevelLocalSkills(syncPaths, { protectedSkillNames });
2024
2090
  }
2025
2091
  const localSkills = (await deps.scanLocalSkills(syncPaths)).filter((skill) => !BASE_SKILL_NAMES.has(skill.name));