@notis_ai/cli 0.2.0-beta.178.1 → 0.2.0-beta.1792022492092.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 (124) hide show
  1. package/README.md +548 -98
  2. package/config/portal-route-roots.json +47 -0
  3. package/dist/agent-hooks/notis-agent-hook.mjs +32901 -5934
  4. package/dist/base-skills/notis-apps/SKILL.md +76 -51
  5. package/dist/base-skills/notis-apps/references/architecture.md +116 -164
  6. package/dist/base-skills/notis-apps/references/design.md +26 -107
  7. package/dist/base-skills/notis-apps/references/memory.md +58 -0
  8. package/dist/base-skills/notis-apps/references/reading.md +52 -74
  9. package/dist/base-skills/notis-apps/references/release.md +105 -96
  10. package/dist/base-skills/notis-apps/references/sdk.md +115 -60
  11. package/dist/base-skills/notis-apps/references/troubleshooting.md +22 -21
  12. package/dist/base-skills/notis-apps/references/views.md +145 -0
  13. package/dist/base-skills/notis-cli/SKILL.md +29 -12
  14. package/dist/base-skills/notis-cli/references/app-delivery.md +12 -11
  15. package/dist/base-skills/notis-cli/references/intelligence.md +94 -0
  16. package/dist/base-skills/notis-cli/references/native-databases.md +6 -5
  17. package/dist/base-skills/notis-cli/references/tool-examples.md +4 -4
  18. package/dist/base-skills/notis-query/SKILL.md +34 -60
  19. package/dist/base-skills/notis-query/references/database-discovery.md +60 -59
  20. package/dist/base-skills/notis-query/references/documents.md +53 -39
  21. package/dist/base-skills/notis-query/references/query.md +57 -524
  22. package/dist/sdk/src/bundleModule.ts +79 -0
  23. package/dist/sdk/src/components/DocumentEditor.tsx +6 -3
  24. package/dist/sdk/src/components/DocumentPage.tsx +144 -0
  25. package/dist/sdk/src/components/RecordComponents.tsx +33 -0
  26. package/dist/sdk/src/components/RenderChartData.tsx +19 -0
  27. package/dist/sdk/src/config.ts +4 -26
  28. package/dist/sdk/src/documents.ts +2 -0
  29. package/dist/sdk/src/hooks/useAction.ts +28 -0
  30. package/dist/sdk/src/hooks/useBackend.ts +3 -2
  31. package/dist/sdk/src/hooks/useCloudComputer.ts +35 -4
  32. package/dist/sdk/src/hooks/useCloudComputerShell.ts +114 -0
  33. package/dist/sdk/src/hooks/useCollectionInteractions.ts +4 -2
  34. package/dist/sdk/src/hooks/useHandover.ts +7 -7
  35. package/dist/sdk/src/hooks/useNativeDocumentBody.ts +50 -0
  36. package/dist/sdk/src/hooks/useNativeDocuments.ts +83 -0
  37. package/dist/sdk/src/hooks/useNotis.ts +3 -0
  38. package/dist/sdk/src/hooks/useNotisNavigation.ts +26 -11
  39. package/dist/sdk/src/hooks/useQuery.ts +7 -4
  40. package/dist/sdk/src/hooks/useViewParams.ts +121 -0
  41. package/dist/sdk/src/hooks/useViewerReads.ts +129 -0
  42. package/dist/sdk/src/index.ts +36 -0
  43. package/dist/sdk/src/interactions/shortcuts.tsx +42 -5
  44. package/dist/sdk/src/nativeData.ts +39 -0
  45. package/dist/sdk/src/presentation.tsx +82 -0
  46. package/dist/sdk/src/provider.tsx +9 -4
  47. package/dist/sdk/src/queryCache.ts +5 -3
  48. package/dist/sdk/src/queryErrors.ts +51 -0
  49. package/dist/sdk/src/runtime.ts +163 -13
  50. package/dist/sdk/src/space.ts +425 -0
  51. package/dist/sdk/src/styles.css +96 -72
  52. package/dist/sdk/src/vite.ts +11 -1
  53. package/dist/skill-sync/index.js +571 -80
  54. package/dist/skill-sync/index.js.map +4 -4
  55. package/dist/skill-sync-worker.mjs +858 -233
  56. package/dist/space-harness/host.js +66 -0
  57. package/dist/view-renderer/dist/host.css +1 -0
  58. package/dist/view-renderer/dist/host.js +1028 -0
  59. package/dist/view-renderer/dist/theme.css +73 -0
  60. package/dist/view-renderer/src/dom.js +180 -0
  61. package/dist/view-renderer/src/errors.js +8 -0
  62. package/dist/view-renderer/src/host.tsx +175 -0
  63. package/dist/view-renderer/src/index.js +4 -0
  64. package/dist/view-renderer/src/pool.js +76 -0
  65. package/dist/view-renderer/src/remote-broker.js +59 -0
  66. package/dist/view-renderer/src/renderer.js +201 -0
  67. package/package.json +16 -5
  68. package/skills/notis-apps/cli.md +323 -163
  69. package/skills/notis-cli/AGENT_INSTRUCTIONS.md +3 -3
  70. package/skills/notis-onboarding/BRIEF.md +7 -3
  71. package/src/command-specs/apps.js +2 -1
  72. package/src/command-specs/auth.js +2 -2
  73. package/src/command-specs/files.js +29 -0
  74. package/src/command-specs/handover.js +331 -79
  75. package/src/command-specs/helpers.js +2 -0
  76. package/src/command-specs/index.js +11 -5
  77. package/src/command-specs/profile.js +42 -1
  78. package/src/command-specs/skills.js +242 -1
  79. package/src/command-specs/spaces.js +645 -0
  80. package/src/command-specs/tools.js +0 -6
  81. package/src/command-specs/views.js +69 -0
  82. package/src/runtime/agent-browser.js +92 -29
  83. package/src/runtime/app-boundary-validator.js +7 -4
  84. package/src/runtime/app-data-literals.js +82 -0
  85. package/src/runtime/app-platform.js +321 -253
  86. package/src/runtime/computer-identity.d.ts +30 -0
  87. package/src/runtime/computer-identity.js +151 -0
  88. package/src/runtime/config-lock.js +135 -0
  89. package/src/runtime/confined-space-build.js +74 -0
  90. package/src/runtime/confined-space-verify.js +49 -0
  91. package/src/runtime/delegated-context.js +5 -21
  92. package/src/runtime/native-record-files.js +47 -0
  93. package/src/runtime/oauth.js +18 -1
  94. package/src/runtime/profiles.js +5 -107
  95. package/src/runtime/retired-sdk-files.js +20 -0
  96. package/src/runtime/screenshot-framing.js +85 -0
  97. package/src/runtime/skill-sync/cloud-client.ts +26 -4
  98. package/src/runtime/skill-sync/index.ts +230 -35
  99. package/src/runtime/skill-sync/local-scanner.ts +188 -29
  100. package/src/runtime/skill-sync/native-identity.ts +102 -0
  101. package/src/runtime/skill-sync/native-plan.ts +70 -0
  102. package/src/runtime/skill-sync/sync-plan.ts +16 -9
  103. package/src/runtime/skill-sync/types.ts +32 -6
  104. package/src/runtime/source-root.js +32 -0
  105. package/src/runtime/space-collection-scaffold.js +66 -0
  106. package/src/runtime/space-database-structures.js +229 -0
  107. package/src/runtime/space-fixture-broker.js +50 -0
  108. package/src/runtime/space-lock.js +337 -0
  109. package/src/runtime/space-platform.js +105 -0
  110. package/src/runtime/space-source.js +281 -0
  111. package/src/runtime/space-test-server.js +327 -0
  112. package/src/runtime/space-verification.js +89 -0
  113. package/src/runtime/space-verify-browser-worker.js +51 -0
  114. package/src/runtime/space-view-manifest.js +254 -0
  115. package/src/runtime/store-screenshot.js +10 -7
  116. package/src/runtime/sync-skills.d.ts +3 -1
  117. package/src/runtime/sync-skills.js +10 -2
  118. package/src/runtime/transport.js +8 -6
  119. package/src/runtime/view-render.js +49 -0
  120. package/src/skill-sync-worker.js +14 -2
  121. package/src/space-harness-host.ts +103 -0
  122. package/template/.harness/index.html.tmpl +7 -10
  123. package/template/app/globals.css +1 -0
  124. package/src/command-specs/reports.js +0 -97
@@ -1,4 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { promises as fs } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
2
4
  import type {
3
5
  AgentTargets,
4
6
  LocalSkill,
@@ -6,8 +8,11 @@ import type {
6
8
  SkillSyncFailure,
7
9
  SyncPullResponse,
8
10
  SyncSettings,
11
+ SyncedSkill,
9
12
  } from "./types";
10
13
  import { normalizeAgentTargets } from "./types";
14
+ import { assertNativePull, checkedNativeReference, isNativeSkill, nativeState } from './native-identity';
15
+ import { bindNativeFolders, hasNativeFolderHistory, nativeLinkRows, nativeLocalReady, pendingLegacyUpgrade, previousNativeState, retainMissingNativeState } from './native-plan';
11
16
  import {
12
17
  downloadSkillBundle,
13
18
  fetchSyncSettings,
@@ -59,6 +64,12 @@ export interface RunSkillSyncOptions {
59
64
  /** Electron's scheduled invocation honors the account preference. A manual
60
65
  * CLI sync always runs, even when automatic Desktop refresh is disabled. */
61
66
  honorSyncEnabled?: boolean;
67
+ /** All account paths, scanner roots and agent links use this explicit home. */
68
+ syncHome?: string;
69
+ /** False when another account is the CLI default: sync this account's own
70
+ * folder only and leave the agent folders (Claude Code, Codex, Cursor) to the
71
+ * default's skills. Defaults to true. */
72
+ linkAgentFolders?: boolean;
62
73
  }
63
74
 
64
75
  const BASE_SKILL_NAMES = new Set(['notis-apps', 'notis-query', 'notis-cli']);
@@ -93,6 +104,8 @@ export interface MaterializeCloudSkillsResult {
93
104
  export interface MaterializeCloudSkillsOptions {
94
105
  /** Server-verified Notis identity, when Desktop's auth subject differs. */
95
106
  canonicalUserId?: string;
107
+ /** Previously authenticated settings must agree with the pull before I/O. */
108
+ syncSettings?: SyncSettings;
96
109
  /** Re-link only these cloud skills without removing or adopting other links. */
97
110
  relinkSkillNames?: readonly string[];
98
111
  }
@@ -134,6 +147,74 @@ const DEFAULT_RUN_SKILL_SYNC_DEPS: RunSkillSyncDependencies = {
134
147
  removeForeignAccountSymlinks,
135
148
  updateAgentTargets,
136
149
  };
150
+
151
+ // Another account is the CLI default and owns the agent folders: this account
152
+ // syncs its own folder without linking, unlinking or adopting anything there.
153
+ const MIRROR_ONLY_DEPS: Pick<
154
+ RunSkillSyncDependencies,
155
+ 'removeForeignAccountSymlinks' | 'removeAllSymlinksForSkill' | 'syncSymlinks'
156
+ > = {
157
+ removeForeignAccountSymlinks: async () => 0,
158
+ removeAllSymlinksForSkill: async () => 0,
159
+ syncSymlinks: async () => ({ linked: 0, removed: 0, skipped: 0, verifiedAgentLinks: {} }),
160
+ };
161
+
162
+ /**
163
+ * The account linking now owns the agent folders, so the links the other
164
+ * accounts on this computer last verified are gone. Forget them: otherwise that
165
+ * account's next linking sync would read each missing link as the user removing
166
+ * the skill and deactivate it.
167
+ */
168
+ async function forgetOtherAccountsAgentLinks(
169
+ paths: ReturnType<typeof getSkillSyncPathsForUser>,
170
+ ): Promise<void> {
171
+ const usersDir = dirname(dirname(paths.syncStatePath));
172
+ let entries: import('node:fs').Dirent[];
173
+ try {
174
+ entries = await fs.readdir(usersDir, { withFileTypes: true });
175
+ } catch {
176
+ return;
177
+ }
178
+ for (const entry of entries) {
179
+ if (!entry.isDirectory()) continue;
180
+ const statePath = join(usersDir, entry.name, '.notis-sync.json');
181
+ if (statePath === paths.syncStatePath) continue;
182
+ let state: { skills?: Record<string, { verifiedAgentLinks?: unknown }> } | null = null;
183
+ try {
184
+ state = JSON.parse(await fs.readFile(statePath, 'utf8'));
185
+ } catch {
186
+ continue;
187
+ }
188
+ if (!state || typeof state !== 'object' || !state.skills || typeof state.skills !== 'object') continue;
189
+ let changed = false;
190
+ for (const skill of Object.values(state.skills)) {
191
+ if (skill && typeof skill === 'object' && 'verifiedAgentLinks' in skill) {
192
+ delete skill.verifiedAgentLinks;
193
+ changed = true;
194
+ }
195
+ }
196
+ if (!changed) continue;
197
+ const temporaryPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
198
+ await fs.writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
199
+ await fs.rename(temporaryPath, statePath);
200
+ }
201
+ }
202
+
203
+ export function isolatedLinkOptions(paths: ReturnType<typeof getSkillSyncPathsForUser>) {
204
+ if (!paths.isolatedNamespace && !paths.homeDir) return undefined;
205
+ const root = paths.isolatedNamespace
206
+ ? join(paths.syncRoot, 'environments', paths.isolatedNamespace, 'agents') : paths.homeDir!;
207
+ return { agentSkillDirs: { notis: paths.skillsDir, claude_code: join(root, '.claude', 'skills'),
208
+ cursor: join(root, '.cursor', 'skills'), codex: join(root, '.codex', 'skills') },
209
+ legacyGlobalSkillsDir: join(root, '.agents', 'skills') };
210
+ }
211
+
212
+ function assertSyncEnvironment(pull: SyncPullResponse, settings: SyncSettings): void {
213
+ if ((pull.sync_namespace || null) !== (settings.sync_namespace || null)
214
+ || (settings.sync_scope !== undefined && pull.sync_scope !== settings.sync_scope)) {
215
+ throw new Error('Skill sync environment changed. Existing mirrors were retained.');
216
+ }
217
+ }
137
218
  function toSkillMap(skills: LocalSkill[]): Map<string, LocalSkill> {
138
219
  return new Map(skills.map((skill) => [skill.name, skill]));
139
220
  }
@@ -175,6 +256,15 @@ export function shouldWriteCloudSkill(
175
256
  return true;
176
257
  }
177
258
 
259
+ if (isNativeSkill(cloudSkill)) {
260
+ if (localSkill.nativeScope && localSkill.nativeScope !== cloudSkill.native_scope) return false;
261
+ if (nativeLocalReady(cloudSkill, localSkill)) return false;
262
+ const nativePrevious = previousNativeState(previousState, cloudSkill);
263
+ return (localSkill.nativeFilesCurrent === true && localSkill.folderHash === localSkill.nativeBaseFolderHash)
264
+ || (!localSkill.nativeReference && nativePrevious?.cloudId === cloudSkill.id
265
+ && nativePrevious.folderHash === localSkill.folderHash);
266
+ }
267
+
178
268
  const previous = previousState.skills[skillName];
179
269
  if (previous?.folderHash === localSkill.folderHash
180
270
  && previous.cloudContentHash === cloudContentHash(cloudSkill)) return false;
@@ -182,10 +272,9 @@ export function shouldWriteCloudSkill(
182
272
  // Without a stored content baseline (skills served through a signed source URL keep
183
273
  // none, because that URL changes on every pull) the only other signal is
184
274
  // `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
275
+ // folder hash is computed. A bundle-entry hash can never equal the hash of the
276
+ // extracted directory, and comparing the two replaced the folder on every sync
277
+ // tick, deleting whatever a running skill had written inside it. Compare cloud against cloud instead: rewrite when the cloud
189
278
  // revision we last applied has actually moved.
190
279
  if (previous
191
280
  && previous.cloudContentHash === undefined
@@ -257,19 +346,34 @@ function buildSyncState(
257
346
  failedContentNames: ReadonlySet<string> = new Set(),
258
347
  appliedRevisions: AppliedCloudRevisions = {},
259
348
  appliedFiles: Record<string, string[] | undefined> = {},
349
+ previousState: NotisSyncState = { version: 1, lastSyncedAt: null, skills: {} },
260
350
  ): NotisSyncState {
261
351
  const localSkillMap = toSkillMap(localSkills);
262
352
  // The same row selection the writer used, so the state describes the revision that
263
353
  // is actually on disk rather than whichever duplicate row the server listed last.
264
- const skills = Object.fromEntries(
265
- selectCloudSkillsToApply(pullResponse.skills).map((skill) => {
354
+ const skills = Object.fromEntries<SyncedSkill>(
355
+ selectCloudSkillsToApply(pullResponse.skills).map((skill): [string, SyncedSkill] => {
266
356
  const localSkill = localSkillMap.get(skill.name);
267
357
  const appliedCloudFolderHash = appliedRevisions[skill.name];
268
358
  const appliedFileList = appliedFiles[skill.name];
359
+ if (pendingLegacyUpgrade(skill, localSkill, previousState)) {
360
+ // Keep the legacy baseline so the upgrade retries; never mark a usable folder unavailable.
361
+ const { nativeUnavailable: _unavailable, ...previous } = previousNativeState(previousState, skill)!;
362
+ return [skill.name, { ...previous, verifiedAgentLinks: verifiedAgentLinks[skill.name] ?? {} }];
363
+ }
364
+ if (isNativeSkill(skill) && !nativeLocalReady(skill, localSkill)) {
365
+ const previous = previousNativeState(previousState, skill);
366
+ return [skill.name, { ...(previous || {
367
+ cloudId: skill.id, folderHash: localSkill?.nativeBaseFolderHash || '',
368
+ agentTargets: normalizeAgentTargets(skill.agent_targets), syncedAt: lastSyncedAt || new Date().toISOString(),
369
+ ...(localSkill?.nativeReference ? { nativeReference: localSkill.nativeReference, nativeScope: localSkill.nativeScope } : {}),
370
+ }), nativeUnavailable: true, verifiedAgentLinks: {} }];
371
+ }
269
372
  return [
270
373
  skill.name,
271
374
  {
272
375
  cloudId: skill.id,
376
+ ...nativeState(skill),
273
377
  folderHash: localSkill?.folderHash || skill.skill_folder_hash || "",
274
378
  agentTargets: normalizeAgentTargets(skill.agent_targets),
275
379
  verifiedAgentLinks: skill.status === "active" ? verifiedAgentLinks[skill.name] ?? {} : {},
@@ -284,11 +388,18 @@ function buildSyncState(
284
388
  }),
285
389
  );
286
390
 
287
- return {
391
+ for (const local of localSkills) {
392
+ if (!local.nativeReference || Object.values(skills).some(row => row.cloudId === local.cloudId)) continue;
393
+ skills[local.name] = { cloudId: local.cloudId!, nativeReference: local.nativeReference,
394
+ nativeScope: local.nativeScope, nativeUnavailable: true, verifiedAgentLinks: {},
395
+ folderHash: local.nativeBaseFolderHash || '', agentTargets: normalizeAgentTargets(),
396
+ syncedAt: lastSyncedAt || new Date().toISOString() };
397
+ }
398
+ return retainMissingNativeState(previousState, {
288
399
  version: 1,
289
400
  lastSyncedAt,
290
401
  skills,
291
- };
402
+ });
292
403
  }
293
404
 
294
405
  function buildLocalSymlinkCandidates(
@@ -301,6 +412,10 @@ function buildLocalSymlinkCandidates(
301
412
  );
302
413
  const localOnlySkills = localSkills
303
414
  .filter((skill) => !cloudSkillNames.has(skill.name))
415
+ .filter(skill => !skill.nativeReference && !previousState.skills[skill.name]?.nativeReference
416
+ && !previousState.skills[skill.name]?.nativeUnavailable
417
+ && !hasNativeFolderHistory(previousState, skill.name)
418
+ && !/^native-[a-f0-9-]{36}$/.test(skill.name))
304
419
  .map((skill) => {
305
420
  const previous = previousState.skills[skill.name];
306
421
  return {
@@ -315,7 +430,7 @@ function buildLocalSymlinkCandidates(
315
430
  };
316
431
  });
317
432
 
318
- return [...pullResponse.skills, ...localOnlySkills];
433
+ return [...nativeLinkRows(pullResponse, localSkills, previousState), ...localOnlySkills];
319
434
  }
320
435
 
321
436
  function isEmptySyncState(state: NotisSyncState): boolean {
@@ -364,6 +479,7 @@ async function writePulledSkillsToScopedMirror(
364
479
  failures: SkillSyncFailure[] = [],
365
480
  writtenSkillNames: Set<string> = new Set(),
366
481
  appliedFilesByName: Record<string, string[]> = {},
482
+ acknowledgedNative: ReadonlyMap<string, string> = new Map(),
367
483
  ): Promise<number> {
368
484
  const localSkillMap = toSkillMap(localSkills);
369
485
  const warnSkillSync = (message: string, error: unknown): void => {
@@ -372,7 +488,16 @@ async function writePulledSkillsToScopedMirror(
372
488
 
373
489
  let downloaded = 0;
374
490
  for (const cloudSkill of selectCloudSkillsToApply(pullResponse.skills)) {
375
- if (!shouldWriteCloudSkill(cloudSkill, localSkillMap, previousState)) {
491
+ const local = localSkillMap.get(cloudSkill.name);
492
+ const acknowledged = isNativeSkill(cloudSkill) && local && acknowledgedNative.get(cloudSkill.id) === local.folderHash;
493
+ // An unchanged pre-native folder has no applied-file record, so the files it shares
494
+ // with the incoming native Skill are the previous sync's output, not local edits.
495
+ const legacyBaseline = Boolean(local && pendingLegacyUpgrade(cloudSkill, local, previousState)
496
+ && previousNativeState(previousState, cloudSkill)?.folderHash === local.folderHash);
497
+ if (!acknowledged && !shouldWriteCloudSkill(cloudSkill, localSkillMap, previousState)) {
498
+ if (isNativeSkill(cloudSkill) && !nativeLocalReady(cloudSkill, local)) {
499
+ failures.push({ name: cloudSkill.name, error: 'Local native Skill changes were retained; resolve the version or identity conflict before using them.' });
500
+ }
376
501
  continue;
377
502
  }
378
503
 
@@ -382,7 +507,9 @@ async function writePulledSkillsToScopedMirror(
382
507
  deps.writeCloudSkillToDisk(skill, bundleBytes, syncPaths, {
383
508
  // What the last write put there. Everything else in the folder was
384
509
  // produced locally and survives this one.
385
- previouslyApplied: previousState.skills[skill.name]?.appliedFiles,
510
+ previouslyApplied: previousNativeState(previousState, skill)?.appliedFiles,
511
+ ...(isNativeSkill(skill) && local ? { expectedFolderHash: local.folderHash } : {}),
512
+ ...(legacyBaseline ? { legacyBaseline } : {}),
386
513
  }),
387
514
  onWarning: warnSkillSync,
388
515
  });
@@ -403,6 +530,8 @@ async function writePulledSkillsToScopedMirror(
403
530
  function assertSkillsPullAuthorized(
404
531
  pullResponse: SyncPullResponse,
405
532
  ): void {
533
+ if (!Array.isArray(pullResponse.skills)) throw new Error('Skill inventory is unavailable; existing files were retained.');
534
+ assertNativePull(pullResponse);
406
535
  if (
407
536
  pullResponse.entitlement_access?.code === "entitlement_upgrade_required"
408
537
  && pullResponse.entitlement_access.entitlement === "skills"
@@ -445,12 +574,14 @@ export async function materializeCloudSkillsForLocalShell(
445
574
  );
446
575
  }
447
576
 
448
- const syncPaths = getSkillSyncPathsForUser(options.canonicalUserId?.trim() || authUserId);
449
- const pullResponse = await deps.pullSkills(serverUrl, jwt);
577
+ let pullResponse = await deps.pullSkills(serverUrl, jwt);
450
578
  assertSkillsPullAuthorized(pullResponse);
579
+ if (options.syncSettings) assertSyncEnvironment(pullResponse, options.syncSettings);
580
+ const syncPaths = getSkillSyncPathsForUser(options.canonicalUserId?.trim() || authUserId, undefined, pullResponse.sync_namespace);
451
581
  const previousState = await deps.readSyncState(syncPaths);
452
582
 
453
583
  const localSkills = await deps.scanLocalSkills(syncPaths);
584
+ pullResponse = bindNativeFolders(pullResponse, localSkills, previousState);
454
585
  const failedDownloads: SkillSyncFailure[] = [];
455
586
  const writtenSkillNames = new Set<string>();
456
587
  const appliedFilesByName: Record<string, string[]> = {};
@@ -478,9 +609,9 @@ export async function materializeCloudSkillsForLocalShell(
478
609
  }
479
610
  if (relinkSkillNames.size > 0) {
480
611
  const relinked = await deps.syncSymlinks(
481
- pullResponse.skills.filter((skill) => relinkSkillNames.has(skill.name)),
612
+ nativeLinkRows(pullResponse, finalLocalSkills, previousState).filter((skill) => relinkSkillNames.has(skill.name)),
482
613
  syncPaths.skillsDir,
483
- { removeUndesired: false },
614
+ { ...isolatedLinkOptions(syncPaths), removeUndesired: false },
484
615
  );
485
616
  failures.push(...(relinked.failures ?? []).filter(
486
617
  (failure) => !failedDownloads.some((download) => download.name === failure.name),
@@ -496,6 +627,7 @@ export async function materializeCloudSkillsForLocalShell(
496
627
  new Set(failedDownloads.map(item => item.name)),
497
628
  collectAppliedCloudRevisions(pullResponse, previousState, writtenSkillNames),
498
629
  collectAppliedFiles(pullResponse, previousState, appliedFilesByName),
630
+ previousState,
499
631
  );
500
632
  // Pull-only refresh is not an upload acknowledgement. Keep content baselines
501
633
  // unless we actually wrote cloud content, and retain cloud-missing entries so
@@ -511,17 +643,28 @@ export async function materializeCloudSkillsForLocalShell(
511
643
  }
512
644
  }
513
645
  materializedState.skills = { ...previousState.skills, ...materializedState.skills };
646
+ let removed = 0;
647
+ for (const [name, previous] of Object.entries(previousState.skills)) {
648
+ if (!previous.nativeReference && !previous.nativeUnavailable) continue;
649
+ const current = pullResponse.skills.find(skill => skill.id === previous.cloudId);
650
+ const local = current && finalLocalSkills.find(row => row.name === current.name);
651
+ if (!current || (!nativeLocalReady(current, local) && !pendingLegacyUpgrade(current, local, previousState))) {
652
+ removed += await deps.removeAllSymlinksForSkill(name, syncPaths.skillsDir, isolatedLinkOptions(syncPaths));
653
+ }
654
+ }
514
655
  await deps.writeSyncState(materializedState, syncPaths);
515
656
 
516
657
  return {
517
658
  materializedSkillNames: pullResponse.skills
518
659
  .filter((skill) => finalLocalSkills.some((local) => local.name === skill.name)
660
+ && (!isNativeSkill(skill) || (skill.status === 'active' && skill.agent_targets?.notis !== false
661
+ && nativeLocalReady(skill, finalLocalSkills.find(local => local.name === skill.name))))
519
662
  && !failedDownloads.some((failure) => failure.name === skill.name))
520
663
  .map((skill) => skill.name),
521
664
  pulled: pullResponse.skills.length,
522
665
  downloaded,
523
666
  deleted: 0,
524
- removed: 0,
667
+ removed,
525
668
  lastSyncedAt,
526
669
  ...(failures.length ? { failedLinks: failures } : {}),
527
670
  };
@@ -546,6 +689,7 @@ async function deactivateDeletedAgentSkills(
546
689
  "detectDeletedAgentSymlinks" | "updateAgentTargets" | "pullSkills"
547
690
  >,
548
691
  failures: SkillSyncFailure[],
692
+ linkOptions?: ReturnType<typeof isolatedLinkOptions>,
549
693
  ): Promise<number> {
550
694
  // First sync (incl. legacy migration) has no reliable "we created this link" signal, so we
551
695
  // cannot tell a user deletion apart from a never-created link — skip detection entirely.
@@ -554,9 +698,13 @@ async function deactivateDeletedAgentSkills(
554
698
  }
555
699
 
556
700
  const deletions = await deps.detectDeletedAgentSymlinks(
557
- pullResponse.skills,
701
+ pullResponse.skills.map(skill => {
702
+ const previous = isNativeSkill(skill) ? Object.entries(previousState.skills).find(([, row]) => row.cloudId === skill.id) : undefined;
703
+ return previous ? { ...skill, name: previous[0] } : skill;
704
+ }),
558
705
  previousState,
559
706
  skillsDir,
707
+ linkOptions,
560
708
  );
561
709
  if (deletions.length === 0) {
562
710
  return 0;
@@ -574,18 +722,24 @@ async function deactivateDeletedAgentSkills(
574
722
 
575
723
  const fresh = withoutBaseSkills(await deps.pullSkills(serverUrl, jwt));
576
724
  assertSkillsPullAuthorized(fresh);
725
+ assertSyncEnvironment(fresh, pullResponse);
577
726
  Object.assign(pullResponse, fresh);
578
727
  let needsRefresh = false;
579
728
  let deactivated = 0;
580
729
  for (const [skillId, { skillName, agents }] of agentsBySkill) {
581
730
  const skill = pullResponse.skills.find((item) => item.id === skillId);
582
731
  const previous = previousState.skills[skillName];
583
- if (!skill?.updated_at || previous?.cloudUpdatedAt !== skill.updated_at) continue;
732
+ if (!skill) continue;
733
+ const native = isNativeSkill(skill);
734
+ if (native) {
735
+ if (!skill.native_settings || previous?.nativeSettingsRevision !== skill.native_settings.revision) continue;
736
+ } else if (!skill.updated_at || previous?.cloudUpdatedAt !== skill.updated_at) continue;
584
737
  const patch = Object.fromEntries([...agents].map((agent) => [agent, false]));
585
738
  try {
586
- const saved = await deps.updateAgentTargets(serverUrl, jwt, skillId, patch, skill.updated_at);
739
+ const saved = await deps.updateAgentTargets(serverUrl, jwt, skillId, patch,
740
+ native ? undefined : skill.updated_at, native ? { reference: skill.native_reference!, settingsRevision: skill.native_settings!.revision } : undefined);
587
741
  if (saved.success !== true || !saved.updated_at?.trim()
588
- || saved.updated_at === skill.updated_at
742
+ || (!native && saved.updated_at === skill.updated_at)
589
743
  || !['notis', 'claude_code', 'cursor', 'codex'].every((agent) =>
590
744
  typeof saved.agent_targets?.[agent as keyof AgentTargets] === 'boolean')
591
745
  || ![...agents].every((agent) => saved.agent_targets[agent] === false)) {
@@ -593,6 +747,7 @@ async function deactivateDeletedAgentSkills(
593
747
  }
594
748
  skill.agent_targets = saved.agent_targets;
595
749
  skill.updated_at = saved.updated_at;
750
+ if (native) needsRefresh = true;
596
751
  deactivated += agents.size;
597
752
  } catch (error) {
598
753
  needsRefresh = true;
@@ -603,6 +758,7 @@ async function deactivateDeletedAgentSkills(
603
758
  if (needsRefresh) {
604
759
  const refreshed = withoutBaseSkills(await deps.pullSkills(serverUrl, jwt));
605
760
  assertSkillsPullAuthorized(refreshed);
761
+ assertSyncEnvironment(refreshed, pullResponse);
606
762
  Object.assign(pullResponse, refreshed);
607
763
  }
608
764
  return deactivated;
@@ -614,9 +770,11 @@ export async function runSkillSync(
614
770
  dependencies: Partial<RunSkillSyncDependencies> = {},
615
771
  options: RunSkillSyncOptions = {},
616
772
  ): Promise<RunSkillSyncResult> {
773
+ const linkAgentFolders = options.linkAgentFolders !== false;
617
774
  const deps = {
618
775
  ...DEFAULT_RUN_SKILL_SYNC_DEPS,
619
776
  ...dependencies,
777
+ ...(linkAgentFolders ? {} : MIRROR_ONLY_DEPS),
620
778
  };
621
779
  const syncSettings: SyncSettings = await deps.fetchSyncSettings(
622
780
  serverUrl,
@@ -633,11 +791,13 @@ export async function runSkillSync(
633
791
  "Cannot sync skills without a server-verified account identity.",
634
792
  );
635
793
  }
636
- const syncPaths = getSkillSyncPathsForUser(syncUserId);
637
- const foreignLinksRemoved = await deps.removeForeignAccountSymlinks(
638
- syncPaths.skillsDir,
639
- );
794
+ const syncPaths = getSkillSyncPathsForUser(syncUserId, undefined, syncSettings.sync_namespace, options.syncHome);
640
795
  if (options.honorSyncEnabled !== false && !syncSettings.sync_enabled) {
796
+ // Disabled sync intentionally performs no pull. Only the authenticated
797
+ // settings scope may remove foreign links; account mirrors stay untouched.
798
+ const foreignLinksRemoved = await deps.removeForeignAccountSymlinks(
799
+ syncPaths.skillsDir, isolatedLinkOptions(syncPaths),
800
+ );
641
801
  return {
642
802
  syncEnabled: false,
643
803
  pushed: 0,
@@ -655,6 +815,13 @@ export async function runSkillSync(
655
815
 
656
816
  let pullResponse = withoutBaseSkills(await deps.pullSkills(serverUrl, jwt));
657
817
  assertSkillsPullAuthorized(pullResponse);
818
+ assertSyncEnvironment(pullResponse, syncSettings);
819
+ const foreignLinksRemoved = await deps.removeForeignAccountSymlinks(
820
+ syncPaths.skillsDir, isolatedLinkOptions(syncPaths),
821
+ );
822
+ if (linkAgentFolders && !syncPaths.isolatedNamespace) {
823
+ await forgetOtherAccountsAgentLinks(syncPaths);
824
+ }
658
825
 
659
826
  const cloudCuratedSkillNames = new Set(
660
827
  pullResponse.skills
@@ -664,50 +831,66 @@ export async function runSkillSync(
664
831
  const protectedSkillNames = new Set([...cloudCuratedSkillNames, ...BASE_SKILL_NAMES]);
665
832
  const scopedState = withoutBaseSkillState(await deps.readSyncState(syncPaths));
666
833
  const assignmentFailures: SkillSyncFailure[] = [];
667
- const deactivated = syncSettings.agent_targets_conditional_updates === true
834
+ // A mirror-only sync has no links by design, so none of them is missing.
835
+ const deactivated = linkAgentFolders && !syncPaths.isolatedNamespace
836
+ && syncSettings.agent_targets_conditional_updates === true
668
837
  ? await deactivateDeletedAgentSkills(
669
- serverUrl, jwt, pullResponse, scopedState, scopedState, syncPaths.skillsDir, deps, assignmentFailures,
838
+ serverUrl, jwt, pullResponse, scopedState, scopedState, syncPaths.skillsDir, deps, assignmentFailures, isolatedLinkOptions(syncPaths),
670
839
  )
671
840
  : 0;
841
+ assertSyncEnvironment(pullResponse, syncSettings);
672
842
  const authUserId = decodeJwtSubject(jwt);
673
843
  let previousAuthState: NotisSyncState | null = null;
674
- if (authUserId && authUserId !== syncUserId) {
675
- const previousAuthPaths = getSkillSyncPathsForUser(authUserId);
844
+ if (!syncPaths.isolatedNamespace && authUserId && authUserId !== syncUserId) {
845
+ const previousAuthPaths = getSkillSyncPathsForUser(authUserId, undefined, undefined, options.syncHome);
676
846
  previousAuthState = await deps.readSyncState(previousAuthPaths);
677
847
  await deps.gatherTopLevelLocalSkills(syncPaths, {
678
848
  sourceRoots: [{ label: "previous-auth-scope", root: previousAuthPaths.skillsDir }],
679
849
  protectedSkillNames,
680
850
  });
681
851
  }
682
- await deps.gatherTopLevelLocalSkills(syncPaths, {
683
- protectedSkillNames,
684
- });
852
+ if (linkAgentFolders && !syncPaths.isolatedNamespace) {
853
+ // Skills found loose in the agent folders belong to the account linking them.
854
+ await deps.gatherTopLevelLocalSkills(syncPaths, { protectedSkillNames });
855
+ }
685
856
  const localSkills = (await deps.scanLocalSkills(syncPaths))
686
857
  .filter((skill) => !BASE_SKILL_NAMES.has(skill.name));
687
858
  const previousState = withoutBaseSkillState(applyLegacyFirstRunState(
688
859
  localSkills,
689
860
  scopedState,
690
- isEmptySyncState(scopedState)
861
+ !syncPaths.isolatedNamespace && isEmptySyncState(scopedState)
691
862
  ? (!previousAuthState || isEmptySyncState(previousAuthState)
692
863
  ? await deps.readLegacySyncState(syncPaths)
693
864
  : previousAuthState)
694
865
  : null,
695
866
  ));
867
+ pullResponse = bindNativeFolders(pullResponse, localSkills, previousState);
696
868
  const gatheredSymlinkResult = await deps.syncSymlinks(
697
869
  buildLocalSymlinkCandidates(pullResponse, localSkills, previousState),
698
870
  syncPaths.skillsDir,
871
+ isolatedLinkOptions(syncPaths),
699
872
  );
700
873
  const pushCandidates = getPushCandidates(
701
874
  localSkills,
702
875
  previousState,
703
876
  cloudCuratedSkillNames,
704
877
  new Set(pullResponse.skills.map((skill) => skill.name)),
705
- new Set(pullResponse.skills.filter((skill) => skill.app_owned || skill.owner_app_id).map((skill) => skill.name)),
878
+ pullResponse.skills,
706
879
  );
707
880
 
708
881
  const failedPushes: SkillSyncFailure[] = [];
882
+ const acknowledgedNative = new Map<string, string>();
883
+ let writeLocalSkills = localSkills;
709
884
  if (pushCandidates.length > 0) {
710
885
  const pushResult = await deps.pushChangedSkills(serverUrl, jwt, pushCandidates);
886
+ for (const result of pushResult.skills || []) {
887
+ const saved = result as { id?: string; native_reference?: unknown };
888
+ const candidate = pushCandidates.find(local => local.nativeReference && local.cloudId === saved.id);
889
+ if (candidate && saved.native_reference) {
890
+ checkedNativeReference(saved.native_reference, candidate.cloudId);
891
+ acknowledgedNative.set(candidate.cloudId!, candidate.folderHash);
892
+ }
893
+ }
711
894
  if (Array.isArray(pushResult?.failed) && pushResult.failed.length > 0) {
712
895
  failedPushes.push(...pushResult.failed);
713
896
  console.warn(
@@ -717,6 +900,9 @@ export async function runSkillSync(
717
900
  }
718
901
  pullResponse = withoutBaseSkills(await deps.pullSkills(serverUrl, jwt));
719
902
  assertSkillsPullAuthorized(pullResponse);
903
+ assertSyncEnvironment(pullResponse, syncSettings);
904
+ writeLocalSkills = (await deps.scanLocalSkills(syncPaths)).filter(skill => !BASE_SKILL_NAMES.has(skill.name));
905
+ pullResponse = bindNativeFolders(pullResponse, writeLocalSkills, previousState);
720
906
  }
721
907
 
722
908
  // Delete phase: remove skills that were previously synced but are no longer in the cloud
@@ -724,8 +910,14 @@ export async function runSkillSync(
724
910
  let deleted = 0;
725
911
  for (const skillName of Object.keys(previousState.skills)) {
726
912
  if (!cloudSkillNames.has(skillName)) {
913
+ const previous = previousState.skills[skillName];
914
+ if (previous.nativeReference || previous.nativeUnavailable
915
+ || pullResponse.skills.some(skill => isNativeSkill(skill) && skill.id === previous.cloudId)) {
916
+ await deps.removeAllSymlinksForSkill(skillName, syncPaths.skillsDir, isolatedLinkOptions(syncPaths));
917
+ continue;
918
+ }
727
919
  await deps.deleteLocalSkill(skillName, syncPaths);
728
- await deps.removeAllSymlinksForSkill(skillName, syncPaths.skillsDir);
920
+ await deps.removeAllSymlinksForSkill(skillName, syncPaths.skillsDir, isolatedLinkOptions(syncPaths));
729
921
  deleted += 1;
730
922
  }
731
923
  }
@@ -735,13 +927,14 @@ export async function runSkillSync(
735
927
  const appliedFilesByName: Record<string, string[]> = {};
736
928
  const downloaded = await writePulledSkillsToScopedMirror(
737
929
  pullResponse,
738
- localSkills,
930
+ writeLocalSkills,
739
931
  previousState,
740
932
  syncPaths,
741
933
  deps,
742
934
  failedDownloads,
743
935
  writtenSkillNames,
744
936
  appliedFilesByName,
937
+ acknowledgedNative,
745
938
  );
746
939
 
747
940
  const finalLocalSkills = (await deps.scanLocalSkills(syncPaths))
@@ -749,6 +942,7 @@ export async function runSkillSync(
749
942
  const symlinkResult = await deps.syncSymlinks(
750
943
  buildLocalSymlinkCandidates(pullResponse, finalLocalSkills, previousState),
751
944
  syncPaths.skillsDir,
945
+ isolatedLinkOptions(syncPaths),
752
946
  );
753
947
  const verifiedLinks = { ...(symlinkResult.verifiedAgentLinks ?? {}) };
754
948
  for (const failure of failedDownloads) delete verifiedLinks[failure.name];
@@ -763,6 +957,7 @@ export async function runSkillSync(
763
957
  new Set(failedDownloads.map(item => item.name)),
764
958
  collectAppliedCloudRevisions(pullResponse, previousState, writtenSkillNames),
765
959
  collectAppliedFiles(pullResponse, previousState, appliedFilesByName),
960
+ previousState,
766
961
  ),
767
962
  syncPaths,
768
963
  );